From f000221f782ef09d90efedd94573a63d571d025e Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 25 May 2026 00:29:22 +0200 Subject: [PATCH] refactor(cli): resolve relationship dialect at scan boundary --- .../src/context/scan/local-enrichment.test.ts | 21 +++++++++++++++++++ .../cli/src/context/scan/local-enrichment.ts | 21 ++++++++++++++++++- .../scan/relationship-discovery.test.ts | 19 +++++++++-------- .../context/scan/relationship-discovery.ts | 14 ++++++------- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/context/scan/local-enrichment.test.ts b/packages/cli/src/context/scan/local-enrichment.test.ts index 9647c8b9..65bfe15c 100644 --- a/packages/cli/src/context/scan/local-enrichment.test.ts +++ b/packages/cli/src/context/scan/local-enrichment.test.ts @@ -331,6 +331,27 @@ describe('local scan enrichment', () => { expect(scanConnector.introspect).toHaveBeenCalledTimes(1); }); + it('fails when connector driver and snapshot driver differ', async () => { + const mismatchedConnector: KtxScanConnector = { + ...connector(), + driver: 'mysql', + }; + + await expect( + runLocalScanEnrichment({ + connectionId: 'warehouse', + mode: 'relationships', + detectRelationships: true, + connector: mismatchedConnector, + snapshot, + context: { runId: 'scan-run-driver-mismatch' }, + providers: null, + }), + ).rejects.toThrow( + 'ktx scan connector driver "mysql" does not match snapshot driver "postgres" for connection "warehouse"', + ); + }); + it('runs deterministic relationship detection for relationship scans', async () => { const result = await runLocalScanEnrichment({ connectionId: 'warehouse', diff --git a/packages/cli/src/context/scan/local-enrichment.ts b/packages/cli/src/context/scan/local-enrichment.ts index 545b2ad6..833cb5b1 100644 --- a/packages/cli/src/context/scan/local-enrichment.ts +++ b/packages/cli/src/context/scan/local-enrichment.ts @@ -1,5 +1,6 @@ import pLimit from 'p-limit'; import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; +import { getDialectForDriver } from '../connections/dialects.js'; import { buildDefaultKtxProjectConfig, type KtxScanRelationshipConfig } from '../project/config.js'; import { KtxDescriptionGenerator } from './description-generation.js'; import { buildKtxColumnEmbeddingText } from './embedding-text.js'; @@ -118,6 +119,18 @@ function targetMatchesForeignKey(table: KtxEnrichedTable, foreignKey: KtxSchemaF ); } +function assertConnectorDriverMatchesSnapshot(input: { + connector: KtxScanConnector; + snapshot: KtxSchemaSnapshot; + connectionId: string; +}): void { + if (input.connector.driver !== input.snapshot.driver) { + throw new Error( + `ktx scan connector driver "${input.connector.driver}" does not match snapshot driver "${input.snapshot.driver}" for connection "${input.connectionId}"`, + ); + } +} + function formalRelationshipsFromSnapshot( snapshot: KtxSchemaSnapshot, tables: readonly KtxEnrichedTable[], @@ -468,6 +481,12 @@ export async function runLocalScanEnrichment( )); await progress?.update(0.05, `Loaded schema snapshot with ${snapshot.tables.length} tables`); + assertConnectorDriverMatchesSnapshot({ + connector: input.connector, + snapshot, + connectionId: input.connectionId, + }); + const dialect = getDialectForDriver(snapshot.driver); const now = input.now ?? (() => new Date()); const state = completedKtxScanEnrichmentStateSummary(); const syncId = input.syncId ?? input.context.runId; @@ -575,7 +594,7 @@ export async function runLocalScanEnrichment( await relationshipProgress?.update(0, 'Detecting relationships'); const detection = await discoverKtxRelationships({ connectionId: input.connectionId, - driver: snapshot.driver, + dialect, connector: input.connector, schema, context: input.context, diff --git a/packages/cli/src/context/scan/relationship-discovery.test.ts b/packages/cli/src/context/scan/relationship-discovery.test.ts index 400fae62..3151ed32 100644 --- a/packages/cli/src/context/scan/relationship-discovery.test.ts +++ b/packages/cli/src/context/scan/relationship-discovery.test.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; +import { getDialectForDriver } from '../connections/dialects.js'; import { buildDefaultKtxProjectConfig } from '../project/config.js'; import { snapshotToKtxEnrichedSchema } from './local-enrichment.js'; import { @@ -308,7 +309,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'relationship-run-1' }, @@ -347,7 +348,7 @@ describe('production relationship discovery', () => { const schema = naturalKeySnapshot(); const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => schema, @@ -397,7 +398,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => sourceSnapshot, @@ -430,7 +431,7 @@ describe('production relationship discovery', () => { it('keeps candidates review-only when read-only SQL is unavailable', async () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(null), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'relationship-run-no-sql' }, @@ -456,7 +457,7 @@ describe('production relationship discovery', () => { const sourceSnapshot = declaredForeignKeySnapshot(); const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(null), schema: snapshotToKtxEnrichedSchema(sourceSnapshot), context: { runId: 'formal-metadata-no-sql' }, @@ -503,7 +504,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(llmOnlyRelationshipSnapshot()), context: { runId: 'llm-relationship-orchestrator' }, @@ -543,7 +544,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: connector(executor), schema: snapshotToKtxEnrichedSchema(snapshot()), context: { runId: 'configured-thresholds' }, @@ -604,7 +605,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: 'warehouse', - driver: 'sqlite', + dialect: getDialectForDriver('sqlite'), connector: { ...connector(executor), introspect: async () => richSnapshot, @@ -658,7 +659,7 @@ describe('production relationship discovery', () => { const result = await discoverKtxRelationships({ connectionId: maskedSnapshot.connectionId, - driver: maskedSnapshot.driver, + dialect: getDialectForDriver(maskedSnapshot.driver), connector: testConnector, schema: snapshotToKtxEnrichedSchema(maskedSnapshot, new Map()), context: { runId: 'test:production-composite' }, diff --git a/packages/cli/src/context/scan/relationship-discovery.ts b/packages/cli/src/context/scan/relationship-discovery.ts index 66a47395..96f73e85 100644 --- a/packages/cli/src/context/scan/relationship-discovery.ts +++ b/packages/cli/src/context/scan/relationship-discovery.ts @@ -1,4 +1,5 @@ import type { KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; +import type { KtxDialect } from '../connections/dialects.js'; import type { KtxScanRelationshipConfig } from '../project/config.js'; import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipUpdate } from './enrichment-types.js'; import { @@ -24,7 +25,6 @@ import { } from './relationship-profiling.js'; import { validateKtxRelationshipDiscoveryCandidates } from './relationship-validation.js'; import type { - KtxConnectionDriver, KtxScanConnector, KtxScanContext, KtxScanEnrichmentSummary, @@ -34,7 +34,7 @@ import type { export interface DiscoverKtxRelationshipsInput { connectionId: string; - driver: KtxConnectionDriver; + dialect: KtxDialect; connector: KtxScanConnector; schema: KtxEnrichedSchema; context: KtxScanContext; @@ -122,7 +122,7 @@ function compositeSummary(relationships: readonly KtxCompositeRelationshipCandid async function detectCompositeRelationships(input: { connectionId: string; - driver: DiscoverKtxRelationshipsInput['driver']; + dialect: KtxDialect; schema: KtxEnrichedSchema; profile: KtxRelationshipProfileArtifact; executor: KtxRelationshipReadOnlyExecutor | null; @@ -135,7 +135,7 @@ async function detectCompositeRelationships(input: { try { const compositeDetection = await discoverKtxCompositeRelationships({ connectionId: input.connectionId, - driver: input.driver, + driver: input.dialect.type, schema: input.schema, profiles: input.profile, executor: input.executor, @@ -223,7 +223,7 @@ export async function discoverKtxRelationships( const profileCache = createKtxRelationshipProfileCache(); const profile = await profileKtxRelationshipSchema({ connectionId: input.connectionId, - driver: input.driver, + driver: input.dialect.type, schema: input.schema, executor, ctx: input.context, @@ -256,7 +256,7 @@ export async function discoverKtxRelationships( warnings.push(...llmProposalResult.warnings); const validated = await validateKtxRelationshipDiscoveryCandidates({ connectionId: input.connectionId, - driver: input.driver, + driver: input.dialect.type, candidates, profiles: profile, executor, @@ -282,7 +282,7 @@ export async function discoverKtxRelationships( }); const compositeRelationships = await detectCompositeRelationships({ connectionId: input.connectionId, - driver: input.driver, + dialect: input.dialect, schema: input.schema, profile, executor,