mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
fix: store Metabase mappings in ktx.yaml (#61)
* fix: store Metabase mappings in ktx.yaml * docs: note KTX has no public users * refactor: drop setup progress compatibility
This commit is contained in:
parent
c22248dabf
commit
b75576279c
43 changed files with 715 additions and 1351 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LocalMetabaseSourceStateReader } from '@ktx/context/ingest';
|
||||
import { initKtxProject, loadKtxProject, serializeKtxProjectConfig } from '@ktx/context/project';
|
||||
import { LocalMetabaseDiscoveryCache } from '@ktx/context/ingest';
|
||||
import { initKtxProject, loadKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxConnectionMapping } from './connection-mapping.js';
|
||||
|
||||
|
|
@ -79,19 +79,24 @@ describe('runKtxConnectionMapping', () => {
|
|||
|
||||
it('sets, lists, disables, and clears local Metabase mappings', async () => {
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKtxConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
const setCode = await runKtxConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
);
|
||||
expect(setCode, io.stderr()).toBe(0);
|
||||
|
||||
let config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['prod-metabase']?.mappings).toMatchObject({
|
||||
databaseMappings: { '1': 'prod-warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
});
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
|
|
@ -113,6 +118,12 @@ describe('runKtxConnectionMapping', () => {
|
|||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['prod-metabase']?.mappings).toMatchObject({
|
||||
databaseMappings: { '1': 'prod-warehouse' },
|
||||
syncEnabled: { '1': false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKtxConnectionMapping(
|
||||
{
|
||||
|
|
@ -124,6 +135,9 @@ describe('runKtxConnectionMapping', () => {
|
|||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['prod-metabase']?.mappings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists Metabase yaml mapping bootstrap rows before any SQLite command writes', async () => {
|
||||
|
|
@ -194,9 +208,11 @@ describe('runKtxConnectionMapping', () => {
|
|||
|
||||
expect(io.stdout()).toContain('Discovery: 1 database');
|
||||
expect(client.cleanup).toHaveBeenCalledTimes(1);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: join(projectDir, '.ktx', 'db.sqlite') });
|
||||
await expect(store.listDatabaseMappings('prod-metabase')).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 1, metabaseDatabaseName: 'Analytics', source: 'refresh' },
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['prod-metabase']?.mappings).toBeUndefined();
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: join(projectDir, '.ktx', 'db.sqlite') });
|
||||
await expect(discoveryCache.listDiscoveredDatabases('prod-metabase')).resolves.toMatchObject([
|
||||
{ id: 1, name: 'Analytics', engine: 'postgres' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import {
|
|||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultLookerConnectionClientFactory,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
KtxYamlMetabaseSourceStateReader,
|
||||
LocalLookerRuntimeStore,
|
||||
LocalMetabaseSourceStateReader,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
computeLookerMappingDrift,
|
||||
computeMetabaseMappingDrift,
|
||||
discoverLookerConnections,
|
||||
|
|
@ -16,10 +17,18 @@ import {
|
|||
validateLookerMappings,
|
||||
validateMappingPhysicalMatch,
|
||||
type LookerMappingClient,
|
||||
type LocalMetabaseMappingListRow,
|
||||
type MetabaseRuntimeClient,
|
||||
type MetabaseSyncMode,
|
||||
} from '@ktx/context/ingest';
|
||||
import { type KtxLocalProject, ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type KtxLocalProject,
|
||||
type KtxProjectConfig,
|
||||
ktxLocalStateDbPath,
|
||||
loadKtxProject,
|
||||
parseMetabaseMappingBootstrap,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from '../index.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -84,6 +93,89 @@ function parseId(value: string, label: string): number {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
interface MetabaseMappingsBlock {
|
||||
databaseMappings: Record<string, string | null>;
|
||||
syncEnabled: Record<string, boolean>;
|
||||
syncMode: MetabaseSyncMode;
|
||||
selections: { collections: number[]; items: number[] };
|
||||
defaultTagNames: string[];
|
||||
}
|
||||
|
||||
function currentMetabaseMappings(project: KtxLocalProject, connectionId: string): MetabaseMappingsBlock {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
const bootstrap = parseMetabaseMappingBootstrap(connectionId, connection);
|
||||
return {
|
||||
databaseMappings: { ...bootstrap.databaseMappings },
|
||||
syncEnabled: { ...bootstrap.syncEnabled },
|
||||
syncMode: bootstrap.syncMode,
|
||||
selections: {
|
||||
collections: [...bootstrap.selections.collections],
|
||||
items: [...bootstrap.selections.items],
|
||||
},
|
||||
defaultTagNames: [...bootstrap.defaultTagNames],
|
||||
};
|
||||
}
|
||||
|
||||
function hasMetabaseMappings(block: MetabaseMappingsBlock): boolean {
|
||||
return (
|
||||
Object.keys(block.databaseMappings).length > 0 ||
|
||||
Object.keys(block.syncEnabled).length > 0 ||
|
||||
block.syncMode !== 'ALL' ||
|
||||
block.selections.collections.length > 0 ||
|
||||
block.selections.items.length > 0 ||
|
||||
block.defaultTagNames.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function serializeMetabaseMappingsBlock(block: MetabaseMappingsBlock): Record<string, unknown> | undefined {
|
||||
if (!hasMetabaseMappings(block)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
databaseMappings: block.databaseMappings,
|
||||
syncEnabled: block.syncEnabled,
|
||||
syncMode: block.syncMode,
|
||||
selections: block.selections,
|
||||
defaultTagNames: block.defaultTagNames,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeMetabaseMappings(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
block: MetabaseMappingsBlock,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
const mappings = serializeMetabaseMappingsBlock(block);
|
||||
const nextConnection = { ...connection };
|
||||
if (mappings) {
|
||||
nextConnection.mappings = mappings;
|
||||
} else {
|
||||
delete nextConnection.mappings;
|
||||
}
|
||||
const nextConfig: KtxProjectConfig = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[connectionId]: nextConnection,
|
||||
},
|
||||
};
|
||||
await project.fileStore.writeFile(
|
||||
'ktx.yaml',
|
||||
serializeKtxProjectConfig(nextConfig),
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
|
|
@ -149,9 +241,7 @@ function targetPhysicalInfo(project: KtxLocalProject, connectionId: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function renderMapping(
|
||||
row: Awaited<ReturnType<LocalMetabaseSourceStateReader['listDatabaseMappings']>>[number],
|
||||
): string {
|
||||
function renderMapping(row: LocalMetabaseMappingListRow): string {
|
||||
const name = row.metabaseDatabaseName ?? 'unhydrated';
|
||||
const target = row.targetConnectionId ?? '[unmapped]';
|
||||
return `${row.metabaseDatabaseId} -> ${target} (${name}, sync: ${row.syncEnabled ? 'on' : 'off'}, source: ${
|
||||
|
|
@ -255,92 +345,78 @@ export async function runKtxConnectionMapping(
|
|||
}
|
||||
|
||||
assertMetabaseConnection(project, args.connectionId);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(project) });
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: ktxLocalStateDbPath(project) });
|
||||
const metabaseStateReader = new KtxYamlMetabaseSourceStateReader(project, { discoveryCache });
|
||||
|
||||
if (args.command === 'list') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
const rows = await metabaseStateReader.listDatabaseMappings(args.connectionId);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(rows, null, 2)}\n` : `${rows.map(renderMapping).join('\n')}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set') {
|
||||
if (args.field !== 'databaseMappings') {
|
||||
throw new Error('Metabase mapping set requires databaseMappings <metabaseDatabaseId>=<targetConnectionId>');
|
||||
}
|
||||
assertTargetConnection(project, args.value);
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: parseId(args.key, 'metabaseDatabaseId'),
|
||||
targetConnectionId: args.value,
|
||||
syncEnabled: true,
|
||||
source: 'cli',
|
||||
});
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
const metabaseDatabaseId = String(parseId(args.key, 'metabaseDatabaseId'));
|
||||
block.databaseMappings[metabaseDatabaseId] = args.value;
|
||||
block.syncEnabled[metabaseDatabaseId] = true;
|
||||
await writeMetabaseMappings(project, args.connectionId, block, `Set Metabase mapping ${args.connectionId}.${metabaseDatabaseId}`);
|
||||
io.stdout.write(`Set databaseMappings.${args.key} = ${args.value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'apply-bulk') {
|
||||
const payload = JSON.parse(await readFile(args.filePath, 'utf8')) as MetabaseBulkMappingPayload;
|
||||
const existingState = await store.getSourceState(args.connectionId);
|
||||
const existingRows = await store.listDatabaseMappings(args.connectionId);
|
||||
const existingById = new Map(existingRows.map((row) => [row.metabaseDatabaseId, row]));
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
const databaseMappings = payload.databaseMappings ?? {};
|
||||
for (const targetConnectionId of Object.values(databaseMappings)) {
|
||||
if (targetConnectionId) {
|
||||
assertTargetConnection(project, targetConnectionId);
|
||||
}
|
||||
}
|
||||
const mappingIds = new Set([
|
||||
...existingRows.map((row) => row.metabaseDatabaseId),
|
||||
...Object.keys(databaseMappings).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
...Object.keys(payload.syncEnabled ?? {}).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
]);
|
||||
await store.replaceSourceState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: payload.syncMode ?? existingState.syncMode,
|
||||
defaultTagNames: payload.defaultTagNames ?? existingState.defaultTagNames,
|
||||
selections:
|
||||
payload.selections === undefined
|
||||
? existingState.selections
|
||||
: [
|
||||
...(payload.selections.collections ?? []).map((id) => ({
|
||||
selectionType: 'collection' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
...(payload.selections.items ?? []).map((id) => ({
|
||||
selectionType: 'item' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
],
|
||||
mappings: [...mappingIds]
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => {
|
||||
const existing = existingById.get(id);
|
||||
return {
|
||||
metabaseDatabaseId: id,
|
||||
metabaseDatabaseName: existing?.metabaseDatabaseName ?? null,
|
||||
metabaseEngine: existing?.metabaseEngine ?? null,
|
||||
metabaseHost: existing?.metabaseHost ?? null,
|
||||
metabaseDbName: existing?.metabaseDbName ?? null,
|
||||
targetConnectionId: databaseMappings[String(id)] ?? existing?.targetConnectionId ?? null,
|
||||
syncEnabled: payload.syncEnabled?.[String(id)] ?? existing?.syncEnabled ?? false,
|
||||
source: 'cli',
|
||||
};
|
||||
}),
|
||||
});
|
||||
for (const id of Object.keys(databaseMappings)) {
|
||||
parseId(id, 'metabaseDatabaseId');
|
||||
block.databaseMappings[id] = databaseMappings[id] ?? null;
|
||||
}
|
||||
for (const [id, enabled] of Object.entries(payload.syncEnabled ?? {})) {
|
||||
parseId(id, 'metabaseDatabaseId');
|
||||
block.syncEnabled[id] = enabled;
|
||||
}
|
||||
if (payload.syncMode !== undefined) {
|
||||
block.syncMode = payload.syncMode;
|
||||
}
|
||||
if (payload.defaultTagNames !== undefined) {
|
||||
block.defaultTagNames = payload.defaultTagNames;
|
||||
}
|
||||
if (payload.selections !== undefined) {
|
||||
block.selections = {
|
||||
collections: payload.selections.collections ?? [],
|
||||
items: payload.selections.items ?? [],
|
||||
};
|
||||
}
|
||||
await writeMetabaseMappings(project, args.connectionId, block, `Apply Metabase mappings ${args.connectionId}`);
|
||||
io.stdout.write(`Applied bulk mappings for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set-sync-enabled') {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: args.metabaseDatabaseId,
|
||||
syncEnabled: args.enabled,
|
||||
});
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
block.syncEnabled[String(args.metabaseDatabaseId)] = args.enabled;
|
||||
await writeMetabaseMappings(
|
||||
project,
|
||||
args.connectionId,
|
||||
block,
|
||||
`Set Metabase sync ${args.connectionId}.${args.metabaseDatabaseId}`,
|
||||
);
|
||||
io.stdout.write(`Set syncEnabled.${args.metabaseDatabaseId} = ${args.enabled}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sync-state-get') {
|
||||
const state = await store.getSourceState(args.connectionId);
|
||||
const state = await metabaseStateReader.getSourceState(args.connectionId);
|
||||
const payload = {
|
||||
syncMode: state.syncMode,
|
||||
selections: state.selections,
|
||||
|
|
@ -351,15 +427,11 @@ export async function runKtxConnectionMapping(
|
|||
}
|
||||
|
||||
if (args.command === 'sync-state-set') {
|
||||
await store.setSyncState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: args.tagNames,
|
||||
selections: [
|
||||
...args.collectionIds.map((id) => ({ selectionType: 'collection' as const, metabaseObjectId: id })),
|
||||
...args.itemIds.map((id) => ({ selectionType: 'item' as const, metabaseObjectId: id })),
|
||||
],
|
||||
});
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
block.syncMode = args.syncMode;
|
||||
block.defaultTagNames = args.tagNames;
|
||||
block.selections = { collections: args.collectionIds, items: args.itemIds };
|
||||
await writeMetabaseMappings(project, args.connectionId, block, `Set Metabase sync state ${args.connectionId}`);
|
||||
io.stdout.write(`Set sync state for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -368,15 +440,11 @@ export async function runKtxConnectionMapping(
|
|||
const client = await (deps.createMetabaseClient ?? createDefaultMetabaseClient)(project, args.connectionId);
|
||||
try {
|
||||
const discovered = await discoverMetabaseDatabases(client);
|
||||
const existing = Object.fromEntries(
|
||||
(await store.listDatabaseMappings(args.connectionId)).map((row) => [
|
||||
String(row.metabaseDatabaseId),
|
||||
row.targetConnectionId,
|
||||
]),
|
||||
);
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
const existing = block.databaseMappings;
|
||||
const drift = computeMetabaseMappingDrift({ currentMappings: existing, discovered });
|
||||
if (args.autoAccept) {
|
||||
await store.refreshDiscoveredDatabases({ connectionId: args.connectionId, discovered });
|
||||
await discoveryCache.refreshDiscoveredDatabases({ connectionId: args.connectionId, discovered });
|
||||
}
|
||||
io.stdout.write(`Discovery: ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Unmapped discovered: ${drift.unmappedDiscovered.length}\n`);
|
||||
|
|
@ -388,7 +456,9 @@ export async function runKtxConnectionMapping(
|
|||
}
|
||||
|
||||
if (args.command === 'validate') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
const rows = (await metabaseStateReader.listDatabaseMappings(args.connectionId)).filter(
|
||||
(row) => row.source === 'ktx.yaml',
|
||||
);
|
||||
const failures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
|
|
@ -412,7 +482,18 @@ export async function runKtxConnectionMapping(
|
|||
}
|
||||
|
||||
const metabaseDatabaseId = args.metabaseDatabaseId ?? (args.mappingKey ? parseId(args.mappingKey, 'metabaseDatabaseId') : undefined);
|
||||
await store.clearDatabaseMappings({ connectionId: args.connectionId, metabaseDatabaseId });
|
||||
const block = currentMetabaseMappings(project, args.connectionId);
|
||||
if (metabaseDatabaseId === undefined) {
|
||||
block.databaseMappings = {};
|
||||
block.syncEnabled = {};
|
||||
block.syncMode = 'ALL';
|
||||
block.selections = { collections: [], items: [] };
|
||||
block.defaultTagNames = [];
|
||||
} else {
|
||||
delete block.databaseMappings[String(metabaseDatabaseId)];
|
||||
delete block.syncEnabled[String(metabaseDatabaseId)];
|
||||
}
|
||||
await writeMetabaseMappings(project, args.connectionId, block, `Clear Metabase mappings ${args.connectionId}`);
|
||||
io.stdout.write(
|
||||
metabaseDatabaseId
|
||||
? `Cleared databaseMappings.${metabaseDatabaseId}\n`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LocalMetabaseSourceStateReader } from '@ktx/context/ingest';
|
||||
import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from '@ktx/context/ingest';
|
||||
import { initKtxProject, ktxLocalStateDbPath, loadKtxProject, serializeKtxProjectConfig } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -9,6 +9,12 @@ import { runKtxConnectionMetabaseSetup } from './connection-metabase-setup.js';
|
|||
|
||||
const CANCEL_PROMPT = Symbol('cancel');
|
||||
|
||||
async function metabaseMappingRows(projectDir: string, connectionId = 'metabase') {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: ktxLocalStateDbPath(project) });
|
||||
return new KtxYamlMetabaseSourceStateReader(project, { discoveryCache }).listDatabaseMappings(connectionId);
|
||||
}
|
||||
|
||||
function createTestMetabaseSetupPromptAdapter(options: {
|
||||
selects?: Array<string | typeof CANCEL_PROMPT>;
|
||||
multiselects?: Array<Array<unknown> | typeof CANCEL_PROMPT>;
|
||||
|
|
@ -238,10 +244,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
expect(config).toContain('driver: metabase');
|
||||
expect(config).toContain('api_url: http://metabase.example.test:3000');
|
||||
expect(config).toContain('api_key: mb_example');
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{
|
||||
metabaseDatabaseId: 2,
|
||||
metabaseDatabaseName: 'Analytics',
|
||||
|
|
@ -294,10 +297,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
{ createMetabaseClient: async () => metabaseClient as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 2, targetConnectionId: 'orbit', syncEnabled: true },
|
||||
]);
|
||||
});
|
||||
|
|
@ -369,10 +369,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
{ createMetabaseClient: async () => metabaseClient as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 2, targetConnectionId: 'orbit', syncEnabled: true },
|
||||
]);
|
||||
});
|
||||
|
|
@ -659,10 +656,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
{ createMetabaseClient: async () => metabaseClient as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 1, targetConnectionId: 'orbit', syncEnabled: true },
|
||||
{ metabaseDatabaseId: 2, targetConnectionId: null, syncEnabled: false },
|
||||
]);
|
||||
|
|
@ -785,10 +779,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
const config = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(config).toContain('driver: metabase');
|
||||
expect(io.stderr()).toContain(`ktx ingest run --connection-id metabase --adapter metabase --project-dir ${projectDir}`);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 2, targetConnectionId: 'orbit' },
|
||||
]);
|
||||
});
|
||||
|
|
@ -886,10 +877,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
expect(config).toContain('driver: metabase');
|
||||
expect(config).toContain('api_url: http://metabase.example.test:3000');
|
||||
expect(config).toContain(`api_key: ${interactiveMetabaseCredential}`);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{
|
||||
metabaseDatabaseId: 2,
|
||||
targetConnectionId: 'orbit',
|
||||
|
|
@ -957,10 +945,7 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toMatchObject([
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 2, targetConnectionId: 'orbit', syncEnabled: true },
|
||||
{ metabaseDatabaseId: 3, targetConnectionId: 'warehouse2', syncEnabled: false },
|
||||
]);
|
||||
|
|
@ -1128,9 +1113,6 @@ describe('runKtxConnectionMetabaseSetup', () => {
|
|||
|
||||
const afterConfig = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(afterConfig).toBe(beforeConfig);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await expect(store.listDatabaseMappings('metabase')).resolves.toEqual([]);
|
||||
await expect(metabaseMappingRows(projectDir)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import { localConnectionToWarehouseDescriptor } from '@ktx/context/connections';
|
|||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
LocalMetabaseSourceStateReader,
|
||||
KtxYamlMetabaseSourceStateReader,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
MetabaseClient,
|
||||
type MetabaseDatabase,
|
||||
type MetabaseRuntimeClient,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
type KtxProjectConnectionConfig,
|
||||
ktxLocalStateDbPath,
|
||||
loadKtxProject,
|
||||
parseMetabaseMappingBootstrap,
|
||||
serializeKtxProjectConfig,
|
||||
} from '@ktx/context/project';
|
||||
|
||||
|
|
@ -338,6 +340,33 @@ function noteMetabaseSetupSummary(options: {
|
|||
);
|
||||
}
|
||||
|
||||
function metabaseMappingsBlockForSetup(options: {
|
||||
connectionId: string;
|
||||
connection: KtxProjectConnectionConfig;
|
||||
mappings: MetabaseSetupMappingAssignment[];
|
||||
syncEnabledDatabaseIds: number[];
|
||||
syncMode: MetabaseSetupSyncMode;
|
||||
}): Record<string, unknown> {
|
||||
const existing = parseMetabaseMappingBootstrap(options.connectionId, options.connection);
|
||||
const databaseMappings = { ...existing.databaseMappings };
|
||||
const syncEnabled = { ...existing.syncEnabled };
|
||||
for (const mapping of options.mappings) {
|
||||
const key = String(mapping.metabaseDatabaseId);
|
||||
databaseMappings[key] = mapping.targetConnectionId;
|
||||
syncEnabled[key] = false;
|
||||
}
|
||||
for (const metabaseDatabaseId of options.syncEnabledDatabaseIds) {
|
||||
syncEnabled[String(metabaseDatabaseId)] = true;
|
||||
}
|
||||
return {
|
||||
databaseMappings,
|
||||
syncEnabled,
|
||||
syncMode: options.syncMode,
|
||||
selections: existing.selections,
|
||||
defaultTagNames: existing.defaultTagNames,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runKtxConnectionMetabaseSetup(
|
||||
args: KtxConnectionMetabaseSetupArgs,
|
||||
io: KtxCliIo,
|
||||
|
|
@ -674,54 +703,37 @@ export async function runKtxConnectionMetabaseSetup(
|
|||
}
|
||||
}
|
||||
|
||||
const finalConnectionConfig: KtxProjectConnectionConfig = {
|
||||
...transientConnectionConfig,
|
||||
mappings: metabaseMappingsBlockForSetup({
|
||||
connectionId,
|
||||
connection: transientConnectionConfig,
|
||||
mappings: resolvedMappings,
|
||||
syncEnabledDatabaseIds: resolvedSyncEnabledDatabaseIds,
|
||||
syncMode: args.syncMode,
|
||||
}),
|
||||
};
|
||||
const finalConfig = {
|
||||
...configWithTransient,
|
||||
connections: {
|
||||
...configWithTransient.connections,
|
||||
[connectionId]: finalConnectionConfig,
|
||||
},
|
||||
};
|
||||
await project.fileStore.writeFile(
|
||||
'ktx.yaml',
|
||||
serializeKtxProjectConfig(configWithTransient),
|
||||
serializeKtxProjectConfig(finalConfig),
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
`Setup Metabase connection ${connectionId}`,
|
||||
);
|
||||
|
||||
const updatedProject = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
|
||||
await store.refreshDiscoveredDatabases({ connectionId, discovered });
|
||||
|
||||
for (const mapping of resolvedMappings) {
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId,
|
||||
metabaseDatabaseId: mapping.metabaseDatabaseId,
|
||||
targetConnectionId: mapping.targetConnectionId,
|
||||
syncEnabled: false,
|
||||
source: 'cli',
|
||||
});
|
||||
}
|
||||
|
||||
for (const metabaseDatabaseId of resolvedSyncEnabledDatabaseIds) {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId,
|
||||
metabaseDatabaseId,
|
||||
syncEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
const existingSyncState = await store.getSourceState(connectionId);
|
||||
await store.setSyncState({
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: ktxLocalStateDbPath(updatedProject) });
|
||||
await discoveryCache.refreshDiscoveredDatabases({ connectionId, discovered });
|
||||
const rows = await new KtxYamlMetabaseSourceStateReader(updatedProject, { discoveryCache }).listDatabaseMappings(
|
||||
connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: existingSyncState.defaultTagNames,
|
||||
selections: existingSyncState.selections,
|
||||
});
|
||||
|
||||
const unhydrated = await store.getUnhydratedSyncEnabledMappingIds(connectionId);
|
||||
if (unhydrated.length > 0) {
|
||||
io.stderr.write(
|
||||
`Sync-enabled mappings are missing discovery metadata; run ktx connection mapping refresh ${connectionId} --auto-accept\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rows = await store.listDatabaseMappings(connectionId);
|
||||
);
|
||||
const physicalFailures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||
import { join } from 'node:path';
|
||||
import { AgentRunnerService, type RunLoopParams } from '@ktx/context/agent';
|
||||
import {
|
||||
LocalMetabaseSourceStateReader,
|
||||
KtxYamlMetabaseSourceStateReader,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
MetabaseSourceAdapter,
|
||||
getLocalIngestStatus,
|
||||
type ChunkResult,
|
||||
|
|
@ -493,6 +494,23 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
|
|||
' driver: metabase',
|
||||
' api_url: https://metabase.example.test',
|
||||
' api_key: literal-test-key',
|
||||
' mappings:',
|
||||
' databaseMappings:',
|
||||
' "1": warehouse_a',
|
||||
' syncEnabled:',
|
||||
' "1": true',
|
||||
` syncMode: ${input.syncMode}`,
|
||||
' selections:',
|
||||
` collections: [${input.selections
|
||||
.filter((selection) => selection.selectionType === 'collection')
|
||||
.map((selection) => selection.metabaseObjectId)
|
||||
.join(', ')}]`,
|
||||
` items: [${input.selections
|
||||
.filter((selection) => selection.selectionType === 'item')
|
||||
.map((selection) => selection.metabaseObjectId)
|
||||
.join(', ')}]`,
|
||||
' defaultTagNames:',
|
||||
' - sync-mode-smoke',
|
||||
' warehouse_a:',
|
||||
' driver: postgres',
|
||||
' url: postgresql://readonly@db.example.test/warehouse_a',
|
||||
|
|
@ -507,29 +525,15 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
|
|||
);
|
||||
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(project) });
|
||||
await store.replaceSourceState({
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: ktxLocalStateDbPath(project) });
|
||||
await discoveryCache.refreshDiscoveredDatabases({
|
||||
connectionId: 'prod-metabase',
|
||||
syncMode: input.syncMode,
|
||||
defaultTagNames: ['sync-mode-smoke'],
|
||||
selections: input.selections,
|
||||
mappings: [
|
||||
{
|
||||
metabaseDatabaseId: 1,
|
||||
metabaseDatabaseName: 'Warehouse A',
|
||||
metabaseEngine: 'postgres',
|
||||
metabaseHost: 'db.example.test',
|
||||
metabaseDbName: 'warehouse_a',
|
||||
targetConnectionId: 'warehouse_a',
|
||||
syncEnabled: true,
|
||||
source: 'refresh',
|
||||
},
|
||||
],
|
||||
discovered: [{ id: 1, name: 'Warehouse A', engine: 'postgres', host: 'db.example.test', dbName: 'warehouse_a' }],
|
||||
});
|
||||
|
||||
const adapter = new MetabaseSourceAdapter({
|
||||
clientFactory: new StaticMetabaseClientFactory(createSyncModeMetabaseClient()),
|
||||
sourceStateReader: store,
|
||||
sourceStateReader: new KtxYamlMetabaseSourceStateReader(project, { discoveryCache }),
|
||||
});
|
||||
const jobId = `metabase-sync-mode-${input.name}-child`;
|
||||
const io = makeIo();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import {
|
||||
LocalLookerRuntimeStore,
|
||||
LocalMetabaseSourceStateReader,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
type LocalIngestResult,
|
||||
type LocalMetabaseFanoutProgress,
|
||||
type RunLocalIngestOptions,
|
||||
|
|
@ -433,6 +433,16 @@ describe('runKtxIngest', () => {
|
|||
' driver: metabase',
|
||||
' api_url: https://metabase.example.test',
|
||||
' api_key: literal-test-key',
|
||||
' mappings:',
|
||||
' databaseMappings:',
|
||||
' "1": warehouse_a',
|
||||
' "2": warehouse_b',
|
||||
' syncEnabled:',
|
||||
' "1": true',
|
||||
' "2": true',
|
||||
' syncMode: ALL',
|
||||
' defaultTagNames:',
|
||||
' - ktx',
|
||||
' warehouse_a:',
|
||||
' driver: postgres',
|
||||
' url: postgresql://readonly@db.example.test/warehouse_a',
|
||||
|
|
@ -449,33 +459,12 @@ describe('runKtxIngest', () => {
|
|||
'utf-8',
|
||||
);
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: ktxLocalStateDbPath(project) });
|
||||
await store.replaceSourceState({
|
||||
const discoveryCache = new LocalMetabaseDiscoveryCache({ dbPath: ktxLocalStateDbPath(project) });
|
||||
await discoveryCache.refreshDiscoveredDatabases({
|
||||
connectionId: 'prod-metabase',
|
||||
syncMode: 'ALL',
|
||||
defaultTagNames: ['ktx'],
|
||||
selections: [],
|
||||
mappings: [
|
||||
{
|
||||
metabaseDatabaseId: 1,
|
||||
metabaseDatabaseName: 'Warehouse A',
|
||||
metabaseEngine: 'postgres',
|
||||
metabaseHost: 'db.example.test',
|
||||
metabaseDbName: 'warehouse_a',
|
||||
targetConnectionId: 'warehouse_a',
|
||||
syncEnabled: true,
|
||||
source: 'refresh',
|
||||
},
|
||||
{
|
||||
metabaseDatabaseId: 2,
|
||||
metabaseDatabaseName: 'Warehouse B',
|
||||
metabaseEngine: 'postgres',
|
||||
metabaseHost: 'db.example.test',
|
||||
metabaseDbName: 'warehouse_b',
|
||||
targetConnectionId: 'warehouse_b',
|
||||
syncEnabled: true,
|
||||
source: 'refresh',
|
||||
},
|
||||
discovered: [
|
||||
{ id: 1, name: 'Warehouse A', engine: 'postgres', host: 'db.example.test', dbName: 'warehouse_a' },
|
||||
{ id: 2, name: 'Warehouse B', engine: 'postgres', host: 'db.example.test', dbName: 'warehouse_b' },
|
||||
],
|
||||
});
|
||||
const adapter = new CliMetabaseSourceAdapter();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation } from './prompt-navigation.js';
|
||||
|
|
@ -364,7 +363,7 @@ async function installTarget(input: {
|
|||
|
||||
async function markAgentsComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'agents');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readKtxSetupState } from '@ktx/context/project';
|
||||
import { readKtxSetupState, writeKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -40,12 +40,6 @@ async function writeReadyProject(projectDir: string) {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
' - embeddings',
|
||||
' - databases',
|
||||
' - sources',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -71,6 +65,9 @@ async function writeReadyProject(projectDir: string) {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(projectDir, {
|
||||
completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources'],
|
||||
});
|
||||
}
|
||||
|
||||
async function writeScanReport(
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ import { cancel, isCancel, select } from '@clack/prompts';
|
|||
import {
|
||||
type KtxLocalProject,
|
||||
loadKtxProject,
|
||||
ktxSetupCompletedSteps,
|
||||
markKtxSetupStateStepComplete,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { buildPublicIngestPlan } from './public-ingest.js';
|
||||
|
|
@ -470,7 +468,7 @@ async function defaultVerifyContextReady(projectDir: string): Promise<KtxSetupCo
|
|||
|
||||
async function markContextComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'context');
|
||||
}
|
||||
|
||||
|
|
@ -704,7 +702,7 @@ export async function runKtxSetupContextStep(
|
|||
try {
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const existingState = await readKtxSetupContextState(args.projectDir);
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(args.projectDir));
|
||||
const completedSteps = (await readKtxSetupState(args.projectDir)).completed_steps;
|
||||
if (completedSteps.includes('context') && existingState.status === 'completed') {
|
||||
return { status: 'ready', projectDir: args.projectDir, runId: existingState.runId ?? 'setup-context-completed' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type KtxSetupDatabaseDriver,
|
||||
|
|
@ -548,12 +548,11 @@ describe('setup databases step', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - databases',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({ multiselectValues: [['back']], selectValues: ['continue'] });
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async () => 0);
|
||||
|
|
@ -590,12 +589,11 @@ describe('setup databases step', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - databases',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
selectValues: ['add', 'url', 'continue'],
|
||||
multiselectValues: [['mysql']],
|
||||
|
|
@ -706,12 +704,11 @@ describe('setup databases step', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - databases',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const io = makeIo();
|
||||
const prompts = makePromptAdapter({
|
||||
multiselectValues: [[]],
|
||||
|
|
@ -1124,7 +1121,6 @@ describe('setup databases step', () => {
|
|||
});
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
expect(io.stdout()).toContain('Primary source ready');
|
||||
|
|
@ -1163,7 +1159,6 @@ describe('setup databases step', () => {
|
|||
});
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
});
|
||||
|
|
@ -1213,7 +1208,7 @@ describe('setup databases step', () => {
|
|||
expect(scanConnection).toHaveBeenCalledTimes(2);
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.database_connection_ids).toEqual(['warehouse', 'analytics']);
|
||||
expect(config.setup?.completed_steps).toEqual([]);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('databases');
|
||||
});
|
||||
|
||||
|
|
@ -1239,7 +1234,7 @@ describe('setup databases step', () => {
|
|||
expect(result.status).toBe('failed');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({ driver: 'postgres', url: 'env:DATABASE_URL' });
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('databases');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(io.stderr()).toContain('Structural scan failed for warehouse.');
|
||||
});
|
||||
|
||||
|
|
@ -1544,7 +1539,6 @@ describe('setup databases step', () => {
|
|||
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(io.stdout()).toContain('KTX cannot work until you add a primary source.');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('databases');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
setKtxSetupDatabaseConnectionIds,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxTableListEntry } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
|
@ -1020,7 +1019,7 @@ async function writeConnectionConfig(input: {
|
|||
[input.connectionId]: input.connection,
|
||||
},
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
|
||||
const historicSql =
|
||||
typeof input.connection.historicSql === 'object' &&
|
||||
|
|
@ -1314,7 +1313,7 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void
|
|||
await writeFile(
|
||||
project.configPath,
|
||||
serializeKtxProjectConfig(
|
||||
stripKtxSetupCompletedSteps({
|
||||
{
|
||||
...project.config,
|
||||
ingest: {
|
||||
...project.config.ingest,
|
||||
|
|
@ -1324,7 +1323,7 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void
|
|||
maxConcurrency,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
|
@ -1333,7 +1332,7 @@ async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise<void
|
|||
async function markDatabasesComplete(projectDir: string, connectionIds: string[]): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = setKtxSetupDatabaseConnectionIds(project.config, unique(connectionIds));
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'databases');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type KtxSetupEmbeddingsPromptAdapter, runKtxSetupEmbeddingsStep } from './setup-embeddings.js';
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ describe('setup embeddings step', () => {
|
|||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
|
||||
expect(spinnerEvents).toContainEqual(
|
||||
'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.',
|
||||
|
|
@ -251,7 +251,7 @@ describe('setup embeddings step', () => {
|
|||
sentenceTransformers: { base_url: 'managed:local-embeddings', pathPrefix: '' },
|
||||
});
|
||||
expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings);
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings');
|
||||
});
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ describe('setup embeddings step', () => {
|
|||
|
||||
expect(result.status).toBe('failed');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('embeddings');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
|
||||
expect(io.stderr()).toContain('Prepare the runtime with: ktx dev runtime start --feature local-embeddings');
|
||||
|
|
@ -413,7 +413,7 @@ describe('setup embeddings step', () => {
|
|||
|
||||
expect(result.status).toBe('skipped');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('embeddings');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(config.ingest.embeddings.backend).toBe('deterministic');
|
||||
});
|
||||
|
||||
|
|
@ -450,10 +450,6 @@ describe('setup embeddings step', () => {
|
|||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
' - embeddings',
|
||||
'connections: {}',
|
||||
'ingest:',
|
||||
' embeddings:',
|
||||
|
|
@ -466,6 +462,7 @@ describe('setup embeddings step', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'llm', 'embeddings'] });
|
||||
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ import { resolveKtxConfigReference } from '@ktx/context/core';
|
|||
import {
|
||||
type KtxProjectConfig,
|
||||
type KtxProjectEmbeddingConfig,
|
||||
ktxSetupCompletedSteps,
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
|
@ -110,7 +108,7 @@ function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter {
|
|||
|
||||
async function hasCompletedEmbeddings(projectDir: string, config: KtxProjectConfig): Promise<boolean> {
|
||||
return (
|
||||
ktxSetupCompletedSteps(config, await readKtxSetupState(projectDir)).includes('embeddings') &&
|
||||
(await readKtxSetupState(projectDir)).completed_steps.includes('embeddings') &&
|
||||
config.ingest.embeddings.backend !== 'none' &&
|
||||
config.ingest.embeddings.backend !== 'deterministic' &&
|
||||
typeof config.ingest.embeddings.model === 'string' &&
|
||||
|
|
@ -184,22 +182,20 @@ function embeddingBackendDisplayName(backend: KtxSetupEmbeddingBackend): string
|
|||
|
||||
async function persistEmbeddingConfig(projectDir: string, embeddings: KtxProjectEmbeddingConfig): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = stripKtxSetupCompletedSteps(
|
||||
{
|
||||
...project.config,
|
||||
ingest: {
|
||||
...project.config.ingest,
|
||||
const config = {
|
||||
...project.config,
|
||||
ingest: {
|
||||
...project.config.ingest,
|
||||
embeddings,
|
||||
},
|
||||
scan: {
|
||||
...project.config.scan,
|
||||
enrichment: {
|
||||
...project.config.scan.enrichment,
|
||||
embeddings,
|
||||
},
|
||||
scan: {
|
||||
...project.config.scan,
|
||||
enrichment: {
|
||||
...project.config.scan.enrichment,
|
||||
embeddings,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'embeddings');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState } from '@ktx/context/project';
|
||||
import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
BUNDLED_ANTHROPIC_MODELS,
|
||||
|
|
@ -160,7 +160,7 @@ describe('setup Anthropic model step', () => {
|
|||
promptCaching: { enabled: true },
|
||||
});
|
||||
expect(config.scan.enrichment.mode).toBe('llm');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stdout()).toContain('LLM ready: yes');
|
||||
expect(io.stdout()).not.toContain('sk-ant-test');
|
||||
|
|
@ -199,7 +199,7 @@ describe('setup Anthropic model step', () => {
|
|||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
});
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stdout()).not.toContain('sk-ant-file');
|
||||
});
|
||||
|
|
@ -516,8 +516,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('llm');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(io.stderr()).toContain('Anthropic model health check failed: 401 invalid x-api-key [redacted]');
|
||||
expect(io.stderr()).not.toContain('sk-ant-test');
|
||||
});
|
||||
|
|
@ -553,7 +552,7 @@ describe('setup Anthropic model step', () => {
|
|||
expect(io.stderr()).toContain('Choose a different credential source or model, or Back.');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.llm.models.default).toBe('claude-sonnet-4-6');
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(tempDir)).completed_steps).toContain('llm');
|
||||
expect(io.stderr()).not.toContain('sk-ant-test');
|
||||
});
|
||||
|
|
@ -565,8 +564,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('skipped');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps ?? []).not.toContain('llm');
|
||||
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
});
|
||||
|
||||
it('returns back without writing config when Back is selected', async () => {
|
||||
|
|
@ -650,9 +648,6 @@ describe('setup Anthropic model step', () => {
|
|||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
'connections: {}',
|
||||
'llm:',
|
||||
' provider:',
|
||||
|
|
@ -669,6 +664,7 @@ describe('setup Anthropic model step', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'llm'] });
|
||||
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
await expect(
|
||||
|
|
@ -698,9 +694,6 @@ describe('setup Anthropic model step', () => {
|
|||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
'connections: {}',
|
||||
'llm:',
|
||||
' provider:',
|
||||
|
|
@ -715,6 +708,7 @@ describe('setup Anthropic model step', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'llm'] });
|
||||
|
||||
const healthCheck = vi.fn(async () => ({ ok: true as const }));
|
||||
const io = makeIo();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
|
|
@ -362,19 +361,17 @@ async function chooseModel(
|
|||
|
||||
async function persistLlmConfig(projectDir: string, credentialRef: string, model: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
const config = stripKtxSetupCompletedSteps(
|
||||
{
|
||||
...project.config,
|
||||
llm: buildProjectLlmConfig(project.config.llm, credentialRef, model),
|
||||
scan: {
|
||||
...project.config.scan,
|
||||
enrichment: {
|
||||
const config = {
|
||||
...project.config,
|
||||
llm: buildProjectLlmConfig(project.config.llm, credentialRef, model),
|
||||
scan: {
|
||||
...project.config.scan,
|
||||
enrichment: {
|
||||
...project.config.scan.enrichment,
|
||||
mode: 'llm',
|
||||
mode: 'llm' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'llm');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,7 @@ describe('setup project step', () => {
|
|||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
|
||||
await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined();
|
||||
await expect(readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8')).resolves.toContain('secrets/');
|
||||
|
|
@ -68,7 +67,7 @@ describe('setup project step', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('loads an existing project with --existing and preserves existing setup metadata', async () => {
|
||||
it('loads an existing project with --existing and drops config setup progress', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeFile(
|
||||
|
|
@ -94,9 +93,9 @@ describe('setup project step', () => {
|
|||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup).toEqual({
|
||||
database_connection_ids: ['warehouse'],
|
||||
completed_steps: [],
|
||||
});
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['llm', 'project'] });
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
|
||||
});
|
||||
|
||||
it('creates a missing auto-mode project only when --yes is present in no-input mode', async () => {
|
||||
|
|
@ -152,8 +151,7 @@ describe('setup project step', () => {
|
|||
}),
|
||||
);
|
||||
expect(prompts.text).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(await readKtxSetupState(projectDir)).toEqual({ completed_steps: ['project'] });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ import { basename, join, resolve } from 'node:path';
|
|||
import { cancel, isCancel, select, text } from '@clack/prompts';
|
||||
import {
|
||||
initKtxProject,
|
||||
ktxSetupCompletedSteps,
|
||||
type KtxLocalProject,
|
||||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
mergeKtxSetupGitignoreEntries,
|
||||
readKtxSetupState,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
writeKtxSetupState,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
|
||||
|
|
@ -170,10 +166,7 @@ async function normalizeSetupGitignore(projectDir: string): Promise<void> {
|
|||
}
|
||||
|
||||
async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalProject> {
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(project.projectDir));
|
||||
const config = stripKtxSetupCompletedSteps(project.config);
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
await writeKtxSetupState(project.projectDir, { completed_steps: completedSteps });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(project.projectDir, 'project');
|
||||
await normalizeSetupGitignore(project.projectDir);
|
||||
return await loadKtxProject({ projectDir: project.projectDir });
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ describe('setup sources step', () => {
|
|||
},
|
||||
setup: {
|
||||
...config.setup,
|
||||
completed_steps: config.setup?.completed_steps ?? [],
|
||||
database_connection_ids: ['warehouse'],
|
||||
},
|
||||
}),
|
||||
|
|
@ -137,7 +136,7 @@ describe('setup sources step', () => {
|
|||
projectDir,
|
||||
});
|
||||
|
||||
expect((await readConfig()).setup?.completed_steps).toEqual(undefined);
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
|
||||
expect(io.stdout()).toContain('Context source setup skipped.');
|
||||
});
|
||||
|
|
@ -171,7 +170,7 @@ describe('setup sources step', () => {
|
|||
source_dir: '/repo/dbt',
|
||||
project_name: 'analytics',
|
||||
});
|
||||
expect(config.setup?.completed_steps).toEqual([]);
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect((await readKtxSetupState(projectDir)).completed_steps).toContain('sources');
|
||||
expect(runInitialIngest).toHaveBeenCalledWith(projectDir, 'analytics_dbt', io.io, { inputMode: 'disabled' });
|
||||
});
|
||||
|
|
@ -190,7 +189,7 @@ describe('setup sources step', () => {
|
|||
source: 'metabase',
|
||||
sourceConnectionId: 'prod_metabase',
|
||||
sourceUrl: 'https://metabase.example.com',
|
||||
sourceApiKeyRef: 'env:METABASE_API_KEY',
|
||||
sourceApiKeyRef: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
sourceWarehouseConnectionId: 'warehouse',
|
||||
metabaseDatabaseId: 1,
|
||||
runInitialSourceIngest: false,
|
||||
|
|
@ -204,7 +203,7 @@ describe('setup sources step', () => {
|
|||
expect((await readConfig()).connections.prod_metabase).toMatchObject({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
|
|
@ -225,7 +224,7 @@ describe('setup sources step', () => {
|
|||
inputMode: 'disabled',
|
||||
source: 'notion',
|
||||
sourceConnectionId: 'notion-main',
|
||||
sourceApiKeyRef: 'env:NOTION_TOKEN',
|
||||
sourceApiKeyRef: 'env:NOTION_TOKEN', // pragma: allowlist secret
|
||||
notionCrawlMode: 'selected_roots',
|
||||
notionRootPageIds: ['page-1'],
|
||||
runInitialSourceIngest: false,
|
||||
|
|
@ -256,7 +255,7 @@ describe('setup sources step', () => {
|
|||
inputMode: 'disabled',
|
||||
source: 'notion',
|
||||
sourceConnectionId: 'notion-main',
|
||||
sourceApiKeyRef: 'env:NOTION_TOKEN',
|
||||
sourceApiKeyRef: 'env:NOTION_TOKEN', // pragma: allowlist secret
|
||||
notionCrawlMode: 'all_accessible',
|
||||
notionRootPageIds: ['page-1'],
|
||||
runInitialSourceIngest: false,
|
||||
|
|
@ -480,7 +479,7 @@ describe('setup sources step', () => {
|
|||
),
|
||||
).resolves.toEqual({ status: 'failed', projectDir });
|
||||
|
||||
expect((await readConfig()).setup?.completed_steps ?? []).not.toContain('sources');
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
expect(io.stderr()).toContain('No LookML files found');
|
||||
});
|
||||
|
||||
|
|
@ -766,7 +765,7 @@ describe('setup sources step', () => {
|
|||
connection: {
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
|
|
@ -786,7 +785,7 @@ describe('setup sources step', () => {
|
|||
driver: 'looker',
|
||||
base_url: 'https://looker.example.com',
|
||||
client_id: 'client-id',
|
||||
client_secret_ref: 'env:LOOKER_CLIENT_SECRET',
|
||||
client_secret_ref: 'env:LOOKER_CLIENT_SECRET', // pragma: allowlist secret
|
||||
mappings: { connectionMappings: { warehouse: 'warehouse' } },
|
||||
},
|
||||
deps: {
|
||||
|
|
@ -1032,7 +1031,7 @@ describe('setup sources step', () => {
|
|||
|
||||
expect(testPrompts.multiselect).not.toHaveBeenCalled();
|
||||
expect(io.stdout()).toContain('Connect a primary source before adding context sources.');
|
||||
expect((await readConfig()).setup?.completed_steps ?? []).not.toContain('sources');
|
||||
expect(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
|
||||
});
|
||||
|
||||
it('auto-detects dbt_project.yml at the root of a local path', async () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
loadKtxProject,
|
||||
markKtxSetupStateStepComplete,
|
||||
serializeKtxProjectConfig,
|
||||
stripKtxSetupCompletedSteps,
|
||||
} from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { runKtxConnectionMapping } from './commands/connection-mapping.js';
|
||||
|
|
@ -345,7 +344,7 @@ function fileRepoUrl(sourceDir: string): string {
|
|||
|
||||
async function writeProjectConfig(projectDir: string, config: KtxProjectConfig): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
}
|
||||
|
||||
async function writeSourceConnection(
|
||||
|
|
@ -372,7 +371,7 @@ async function writeSourceConnection(
|
|||
: [...project.config.ingest.adapters, adapter],
|
||||
},
|
||||
};
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(config), 'utf-8');
|
||||
return async () => {
|
||||
const latest = await loadKtxProject({ projectDir });
|
||||
const connections = { ...latest.config.connections };
|
||||
|
|
@ -411,7 +410,7 @@ async function ensureSourceAdapterEnabled(projectDir: string, source: KtxSetupSo
|
|||
|
||||
async function markSourcesComplete(projectDir: string): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(stripKtxSetupCompletedSteps(project.config)), 'utf-8');
|
||||
await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
|
||||
await markKtxSetupStateStepComplete(projectDir, 'sources');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { writeKtxSetupState } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
|
||||
|
|
@ -133,9 +134,6 @@ describe('setup status', () => {
|
|||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' - analytics',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - databases',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -150,6 +148,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });
|
||||
|
||||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
databases: [
|
||||
|
|
@ -167,8 +166,6 @@ describe('setup status', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -178,6 +175,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project'] });
|
||||
|
||||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
databases: [{ connectionId: 'warehouse', ready: false }],
|
||||
|
|
@ -190,9 +188,6 @@ describe('setup status', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - databases',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -202,6 +197,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });
|
||||
|
||||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
databases: [{ connectionId: 'warehouse', ready: true }],
|
||||
|
|
@ -215,9 +211,6 @@ describe('setup status', () => {
|
|||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - sources',
|
||||
'connections:',
|
||||
' docs:',
|
||||
' driver: notion',
|
||||
|
|
@ -230,6 +223,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'sources'] });
|
||||
|
||||
await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
|
||||
sources: [{ connectionId: 'docs', type: 'notion', ready: true }],
|
||||
|
|
@ -268,12 +262,6 @@ describe('setup status', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
' - embeddings',
|
||||
' - databases',
|
||||
' - sources',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -292,6 +280,9 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, {
|
||||
completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources'],
|
||||
});
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-abc123',
|
||||
status: 'running',
|
||||
|
|
@ -324,10 +315,6 @@ describe('setup status', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - databases',
|
||||
' - sources',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -354,6 +341,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases', 'sources'] });
|
||||
await persistLocalBundleReport(
|
||||
tempDir,
|
||||
localFakeBundleReport('metabase-job-1', {
|
||||
|
|
@ -1281,9 +1269,6 @@ describe('setup status', () => {
|
|||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - databases',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1296,6 +1281,7 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -1782,13 +1768,6 @@ describe('setup status', () => {
|
|||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
' - embeddings',
|
||||
' - sources',
|
||||
' - context',
|
||||
' - agents',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
'llm:',
|
||||
|
|
@ -1805,6 +1784,9 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, {
|
||||
completed_steps: ['project', 'llm', 'embeddings', 'sources', 'context', 'agents'],
|
||||
});
|
||||
await writeFile(
|
||||
join(tempDir, '.ktx/agents/install-manifest.json'),
|
||||
JSON.stringify(
|
||||
|
|
@ -1893,12 +1875,6 @@ describe('setup status', () => {
|
|||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' completed_steps:',
|
||||
' - project',
|
||||
' - llm',
|
||||
' - embeddings',
|
||||
' - sources',
|
||||
' - context',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
'llm:',
|
||||
|
|
@ -1915,6 +1891,9 @@ describe('setup status', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, {
|
||||
completed_steps: ['project', 'llm', 'embeddings', 'sources', 'context'],
|
||||
});
|
||||
await writeKtxSetupContextState(tempDir, {
|
||||
runId: 'setup-context-local-ready',
|
||||
status: 'completed',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { cancel, isCancel, select } from '@clack/prompts';
|
|||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import {
|
||||
ktxLocalStateDbPath,
|
||||
ktxSetupCompletedSteps,
|
||||
loadKtxProject,
|
||||
readKtxSetupState,
|
||||
type KtxLocalProject,
|
||||
|
|
@ -297,7 +296,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
};
|
||||
embeddings.ready = embeddingsReady(embeddings);
|
||||
|
||||
const completedSteps = ktxSetupCompletedSteps(project.config, await readKtxSetupState(resolvedProjectDir));
|
||||
const completedSteps = (await readKtxSetupState(resolvedProjectDir)).completed_steps;
|
||||
const contextState = await readKtxSetupContextState(resolvedProjectDir);
|
||||
const setupContextStatus = setupContextStatusFromState(contextState, {
|
||||
completedStep: completedSteps.includes('context'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue