mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
import type {
|
|
KtxEnrichedRelationship,
|
|
KtxRelationshipEndpoint,
|
|
KtxRelationshipType,
|
|
KtxRelationshipUpdate,
|
|
} from './enrichment-types.js';
|
|
import type {
|
|
KtxResolvedRelationshipDiscoveryCandidate,
|
|
KtxResolvedRelationshipStatus,
|
|
} from './relationship-graph-resolver.js';
|
|
import type { KtxCompositeRelationshipCandidate } from './relationship-composite-candidates.js';
|
|
import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js';
|
|
import type { KtxConnectionDriver, KtxScanWarning } from './types.js';
|
|
|
|
export interface KtxRelationshipArtifactEndpoint {
|
|
tableId: string;
|
|
columnIds: string[];
|
|
table: {
|
|
catalog: string | null;
|
|
db: string | null;
|
|
name: string;
|
|
};
|
|
columns: string[];
|
|
}
|
|
|
|
export interface KtxRelationshipArtifactEdge {
|
|
id: string;
|
|
status: KtxResolvedRelationshipStatus;
|
|
source: string;
|
|
from: KtxRelationshipArtifactEndpoint;
|
|
to: KtxRelationshipArtifactEndpoint;
|
|
relationshipType: KtxRelationshipType;
|
|
confidence: number;
|
|
pkScore: number | null;
|
|
fkScore: number | null;
|
|
score: number | null;
|
|
evidence: unknown | null;
|
|
validation: unknown | null;
|
|
graph: unknown | null;
|
|
reasons: string[];
|
|
}
|
|
|
|
export interface KtxRelationshipArtifact {
|
|
connectionId: string;
|
|
accepted: KtxRelationshipArtifactEdge[];
|
|
review: KtxRelationshipArtifactEdge[];
|
|
rejected: KtxRelationshipArtifactEdge[];
|
|
skipped: KtxRelationshipUpdate['skipped'];
|
|
}
|
|
|
|
export interface KtxRelationshipDiagnosticsSummary {
|
|
accepted: number;
|
|
review: number;
|
|
rejected: number;
|
|
skipped: number;
|
|
}
|
|
|
|
export interface KtxRelationshipDiagnosticsValidation {
|
|
available: boolean;
|
|
sqlAvailable: boolean;
|
|
queryCount: number;
|
|
}
|
|
|
|
export interface KtxRelationshipDiagnosticsThresholds {
|
|
acceptThreshold: number;
|
|
reviewThreshold: number;
|
|
}
|
|
|
|
export interface KtxRelationshipDiagnosticsPolicy {
|
|
validationRequiredForManifest: boolean;
|
|
maxCandidatesPerColumn: number;
|
|
profileSampleRows: number;
|
|
validationConcurrency: number;
|
|
}
|
|
|
|
export interface KtxRelationshipDiagnosticsArtifact {
|
|
connectionId: string;
|
|
generatedAt: string;
|
|
summary: KtxRelationshipDiagnosticsSummary;
|
|
noAcceptedReason: string | null;
|
|
candidateCountsBySource: Record<string, number>;
|
|
validation: KtxRelationshipDiagnosticsValidation;
|
|
thresholds: KtxRelationshipDiagnosticsThresholds;
|
|
policy: KtxRelationshipDiagnosticsPolicy;
|
|
warnings: KtxScanWarning[];
|
|
profileWarnings: string[];
|
|
}
|
|
|
|
export interface BuildKtxRelationshipArtifactsInput {
|
|
connectionId: string;
|
|
relationshipUpdate?: KtxRelationshipUpdate | null;
|
|
resolvedRelationships?: readonly KtxResolvedRelationshipDiscoveryCandidate[];
|
|
compositeRelationships?: readonly KtxCompositeRelationshipCandidate[];
|
|
}
|
|
|
|
export interface BuildKtxRelationshipDiagnosticsInput {
|
|
connectionId: string;
|
|
artifacts: KtxRelationshipArtifact;
|
|
profile: KtxRelationshipProfileArtifact;
|
|
warnings?: readonly KtxScanWarning[];
|
|
thresholds?: Partial<KtxRelationshipDiagnosticsThresholds>;
|
|
policy?: Partial<KtxRelationshipDiagnosticsPolicy>;
|
|
generatedAt?: string;
|
|
}
|
|
|
|
export interface EmptyKtxRelationshipProfileArtifactInput {
|
|
connectionId: string;
|
|
driver: KtxConnectionDriver;
|
|
reason: string;
|
|
}
|
|
|
|
const DEFAULT_THRESHOLDS: KtxRelationshipDiagnosticsThresholds = {
|
|
acceptThreshold: 0.85,
|
|
reviewThreshold: 0.55,
|
|
};
|
|
|
|
const DEFAULT_POLICY: KtxRelationshipDiagnosticsPolicy = {
|
|
validationRequiredForManifest: true,
|
|
maxCandidatesPerColumn: 25,
|
|
profileSampleRows: 10000,
|
|
validationConcurrency: 4,
|
|
};
|
|
|
|
function endpointArtifact(endpoint: KtxRelationshipEndpoint): KtxRelationshipArtifactEndpoint {
|
|
return {
|
|
tableId: endpoint.tableId,
|
|
columnIds: endpoint.columnIds,
|
|
table: {
|
|
catalog: endpoint.table.catalog,
|
|
db: endpoint.table.db,
|
|
name: endpoint.table.name,
|
|
},
|
|
columns: endpoint.columns,
|
|
};
|
|
}
|
|
|
|
function uniqueReasons(values: readonly string[]): string[] {
|
|
return Array.from(new Set(values.filter((value) => value.trim().length > 0)));
|
|
}
|
|
|
|
function relationshipUpdateEdge(
|
|
relationship: KtxEnrichedRelationship,
|
|
status: 'accepted' | 'rejected',
|
|
): KtxRelationshipArtifactEdge {
|
|
const acceptedReason = relationship.source === 'formal' ? 'formal_metadata_accepted' : 'accepted_relationship_update';
|
|
return {
|
|
id: relationship.id,
|
|
status,
|
|
source: relationship.source,
|
|
from: endpointArtifact(relationship.from),
|
|
to: endpointArtifact(relationship.to),
|
|
relationshipType: relationship.relationshipType,
|
|
confidence: relationship.confidence,
|
|
pkScore: null,
|
|
fkScore: null,
|
|
score: relationship.confidence,
|
|
evidence: relationship.source === 'formal' ? { source: 'formal_metadata' } : null,
|
|
validation: relationship.source === 'formal' ? { status: 'formal_metadata' } : null,
|
|
graph: null,
|
|
reasons: [status === 'accepted' ? acceptedReason : 'rejected_relationship_update'],
|
|
};
|
|
}
|
|
|
|
function resolvedEdge(candidate: KtxResolvedRelationshipDiscoveryCandidate): KtxRelationshipArtifactEdge {
|
|
return {
|
|
id: candidate.id,
|
|
status: candidate.status,
|
|
source: candidate.source,
|
|
from: endpointArtifact(candidate.from),
|
|
to: endpointArtifact(candidate.to),
|
|
relationshipType: candidate.relationshipType,
|
|
confidence: candidate.confidence,
|
|
pkScore: candidate.pkScore,
|
|
fkScore: candidate.fkScore,
|
|
score: candidate.score,
|
|
evidence: candidate.evidence,
|
|
validation: candidate.validation,
|
|
graph: candidate.graph,
|
|
reasons: uniqueReasons([
|
|
...candidate.evidence.reasons,
|
|
...candidate.validation.reasons,
|
|
...candidate.graph.reasons,
|
|
]),
|
|
};
|
|
}
|
|
|
|
function compositeEndpointArtifact(endpoint: KtxCompositeRelationshipCandidate['from']): KtxRelationshipArtifactEndpoint {
|
|
return {
|
|
tableId: endpoint.tableId,
|
|
columnIds: endpoint.columnIds,
|
|
table: {
|
|
catalog: endpoint.table.catalog,
|
|
db: endpoint.table.db,
|
|
name: endpoint.table.name,
|
|
},
|
|
columns: endpoint.columns,
|
|
};
|
|
}
|
|
|
|
function compositeEdge(candidate: KtxCompositeRelationshipCandidate): KtxRelationshipArtifactEdge {
|
|
return {
|
|
id: candidate.id,
|
|
status: candidate.status,
|
|
source: candidate.source,
|
|
from: compositeEndpointArtifact(candidate.from),
|
|
to: compositeEndpointArtifact(candidate.to),
|
|
relationshipType: candidate.relationshipType,
|
|
confidence: candidate.confidence,
|
|
pkScore: null,
|
|
fkScore: candidate.confidence,
|
|
score: candidate.confidence,
|
|
evidence: { source: candidate.source },
|
|
validation: candidate.validation,
|
|
graph: null,
|
|
reasons: uniqueReasons(candidate.validation.reasons),
|
|
};
|
|
}
|
|
|
|
function emptyArtifacts(connectionId: string): KtxRelationshipArtifact {
|
|
return {
|
|
connectionId,
|
|
accepted: [],
|
|
review: [],
|
|
rejected: [],
|
|
skipped: [],
|
|
};
|
|
}
|
|
|
|
function pushUniqueEdge(edges: KtxRelationshipArtifactEdge[], edge: KtxRelationshipArtifactEdge): void {
|
|
if (!edges.some((item) => item.id === edge.id)) {
|
|
edges.push(edge);
|
|
}
|
|
}
|
|
|
|
export function buildKtxRelationshipArtifacts(input: BuildKtxRelationshipArtifactsInput): KtxRelationshipArtifact {
|
|
const artifacts = emptyArtifacts(input.connectionId);
|
|
|
|
if (input.resolvedRelationships) {
|
|
for (const candidate of input.resolvedRelationships) {
|
|
const edge = resolvedEdge(candidate);
|
|
if (edge.status === 'accepted') {
|
|
pushUniqueEdge(artifacts.accepted, edge);
|
|
} else if (edge.status === 'review') {
|
|
pushUniqueEdge(artifacts.review, edge);
|
|
} else {
|
|
pushUniqueEdge(artifacts.rejected, edge);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const candidate of input.compositeRelationships ?? []) {
|
|
const edge = compositeEdge(candidate);
|
|
if (edge.status === 'accepted') {
|
|
pushUniqueEdge(artifacts.accepted, edge);
|
|
} else if (edge.status === 'review') {
|
|
pushUniqueEdge(artifacts.review, edge);
|
|
} else {
|
|
pushUniqueEdge(artifacts.rejected, edge);
|
|
}
|
|
}
|
|
|
|
const relationshipUpdate = input.relationshipUpdate;
|
|
if (relationshipUpdate) {
|
|
for (const relationship of relationshipUpdate.accepted) {
|
|
pushUniqueEdge(artifacts.accepted, relationshipUpdateEdge(relationship, 'accepted'));
|
|
}
|
|
for (const relationship of relationshipUpdate.rejected) {
|
|
pushUniqueEdge(artifacts.rejected, relationshipUpdateEdge(relationship, 'rejected'));
|
|
}
|
|
artifacts.skipped.push(...relationshipUpdate.skipped);
|
|
}
|
|
|
|
return {
|
|
connectionId: artifacts.connectionId,
|
|
accepted: artifacts.accepted.sort((left, right) => left.id.localeCompare(right.id)),
|
|
review: artifacts.review.sort((left, right) => left.id.localeCompare(right.id)),
|
|
rejected: artifacts.rejected.sort((left, right) => left.id.localeCompare(right.id)),
|
|
skipped: [...artifacts.skipped].sort((left, right) => left.relationshipId.localeCompare(right.relationshipId)),
|
|
};
|
|
}
|
|
|
|
function allEdges(artifacts: KtxRelationshipArtifact): KtxRelationshipArtifactEdge[] {
|
|
return [...artifacts.accepted, ...artifacts.review, ...artifacts.rejected];
|
|
}
|
|
|
|
function candidateCountsBySource(artifacts: KtxRelationshipArtifact): Record<string, number> {
|
|
const counts: Record<string, number> = {};
|
|
for (const edge of allEdges(artifacts)) {
|
|
counts[edge.source] = (counts[edge.source] ?? 0) + 1;
|
|
}
|
|
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
|
|
}
|
|
|
|
function hasReason(artifacts: KtxRelationshipArtifact, reason: string): boolean {
|
|
return allEdges(artifacts).some((edge) => edge.reasons.includes(reason));
|
|
}
|
|
|
|
function noAcceptedReason(input: {
|
|
artifacts: KtxRelationshipArtifact;
|
|
profile: KtxRelationshipProfileArtifact;
|
|
}): string | null {
|
|
if (input.artifacts.accepted.length > 0) {
|
|
return null;
|
|
}
|
|
if (
|
|
input.artifacts.review.length > 0 &&
|
|
(!input.profile.sqlAvailable ||
|
|
hasReason(input.artifacts, 'validation_unavailable') ||
|
|
hasReason(input.artifacts, 'validation_unavailable_review_only'))
|
|
) {
|
|
return 'validation unavailable; review candidates written';
|
|
}
|
|
if (input.artifacts.review.length > 0) {
|
|
return 'relationship candidates require review before manifest writes';
|
|
}
|
|
if (input.artifacts.rejected.length > 0) {
|
|
return 'all candidate pairs were rejected';
|
|
}
|
|
return 'no candidate pairs passed type compatibility';
|
|
}
|
|
|
|
export function emptyKtxRelationshipProfileArtifact(
|
|
input: EmptyKtxRelationshipProfileArtifactInput,
|
|
): KtxRelationshipProfileArtifact {
|
|
return {
|
|
connectionId: input.connectionId,
|
|
driver: input.driver,
|
|
sqlAvailable: false,
|
|
queryCount: 0,
|
|
tables: [],
|
|
columns: {},
|
|
warnings: [input.reason],
|
|
};
|
|
}
|
|
|
|
export function buildKtxRelationshipDiagnostics(
|
|
input: BuildKtxRelationshipDiagnosticsInput,
|
|
): KtxRelationshipDiagnosticsArtifact {
|
|
const thresholds = { ...DEFAULT_THRESHOLDS, ...input.thresholds };
|
|
const policy = { ...DEFAULT_POLICY, ...input.policy };
|
|
const summary: KtxRelationshipDiagnosticsSummary = {
|
|
accepted: input.artifacts.accepted.length,
|
|
review: input.artifacts.review.length,
|
|
rejected: input.artifacts.rejected.length,
|
|
skipped: input.artifacts.skipped.length,
|
|
};
|
|
|
|
return {
|
|
connectionId: input.connectionId,
|
|
generatedAt: input.generatedAt ?? new Date().toISOString(),
|
|
summary,
|
|
noAcceptedReason: noAcceptedReason({ artifacts: input.artifacts, profile: input.profile }),
|
|
candidateCountsBySource: candidateCountsBySource(input.artifacts),
|
|
validation: {
|
|
available: input.profile.sqlAvailable,
|
|
sqlAvailable: input.profile.sqlAvailable,
|
|
queryCount: input.profile.queryCount,
|
|
},
|
|
thresholds,
|
|
policy,
|
|
warnings: [...(input.warnings ?? [])],
|
|
profileWarnings: [...input.profile.warnings],
|
|
};
|
|
}
|