From 483d0d6c157c58c2b902fb9e968c0cd0ccb616b3 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 24 May 2026 01:08:15 +0200 Subject: [PATCH] feat(scan): add constraint discovery warning helper --- .../context/scan/constraint-discovery.test.ts | 70 +++++++++++++++++++ .../src/context/scan/constraint-discovery.ts | 42 +++++++++++ packages/cli/src/context/scan/types.ts | 4 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/context/scan/constraint-discovery.test.ts create mode 100644 packages/cli/src/context/scan/constraint-discovery.ts diff --git a/packages/cli/src/context/scan/constraint-discovery.test.ts b/packages/cli/src/context/scan/constraint-discovery.test.ts new file mode 100644 index 00000000..78620204 --- /dev/null +++ b/packages/cli/src/context/scan/constraint-discovery.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { constraintDiscoveryWarning, tryConstraintQuery } from './constraint-discovery.js'; + +describe('tryConstraintQuery', () => { + it('returns the query value when the query succeeds', async () => { + await expect( + tryConstraintQuery( + { + schema: 'public', + kind: 'primary_key', + isDeniedError: () => false, + }, + async () => ['id'], + ), + ).resolves.toEqual({ ok: true, value: ['id'] }); + }); + + it('returns a recoverable warning when the classifier recognizes denial', async () => { + const error = Object.assign(new Error('permission denied'), { code: '42501' }); + + await expect( + tryConstraintQuery( + { + schema: 'analytics', + kind: 'foreign_key', + isDeniedError: (candidate) => candidate === error, + }, + async () => { + throw error; + }, + ), + ).resolves.toEqual({ + ok: false, + warning: { + code: 'constraint_discovery_unauthorized', + message: 'Skipped foreign-key discovery in analytics (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'analytics', kind: 'foreign_key' }, + }, + }); + }); + + it('rethrows non-denial errors unchanged', async () => { + const error = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + + await expect( + tryConstraintQuery( + { + schema: 'public', + kind: 'primary_key', + isDeniedError: () => false, + }, + async () => { + throw error; + }, + ), + ).rejects.toBe(error); + }); +}); + +describe('constraintDiscoveryWarning', () => { + it('formats stable primary-key warning text and metadata', () => { + expect(constraintDiscoveryWarning({ schema: 'public', kind: 'primary_key' })).toEqual({ + code: 'constraint_discovery_unauthorized', + message: 'Skipped primary-key discovery in public (insufficient grants on system catalogs)', + recoverable: true, + metadata: { schema: 'public', kind: 'primary_key' }, + }); + }); +}); diff --git a/packages/cli/src/context/scan/constraint-discovery.ts b/packages/cli/src/context/scan/constraint-discovery.ts new file mode 100644 index 00000000..d58e9053 --- /dev/null +++ b/packages/cli/src/context/scan/constraint-discovery.ts @@ -0,0 +1,42 @@ +import type { KtxScanWarning } from './types.js'; + +export type ConstraintDiscoveryKind = 'primary_key' | 'foreign_key'; + +export interface ConstraintQueryContext { + schema: string; + kind: ConstraintDiscoveryKind; + isDeniedError: (error: unknown) => boolean; +} + +export type ConstraintQueryOutcome = { ok: true; value: T } | { ok: false; warning: KtxScanWarning }; + +export function constraintDiscoveryWarning(input: { + schema: string; + kind: ConstraintDiscoveryKind; +}): KtxScanWarning { + return { + code: 'constraint_discovery_unauthorized', + message: + `Skipped ${input.kind === 'primary_key' ? 'primary-key' : 'foreign-key'} ` + + `discovery in ${input.schema} (insufficient grants on system catalogs)`, + recoverable: true, + metadata: { schema: input.schema, kind: input.kind }, + }; +} + +export async function tryConstraintQuery( + ctx: ConstraintQueryContext, + fn: () => Promise, +): Promise> { + try { + return { ok: true, value: await fn() }; + } catch (error) { + if (!ctx.isDeniedError(error)) { + throw error; + } + return { + ok: false, + warning: constraintDiscoveryWarning({ schema: ctx.schema, kind: ctx.kind }), + }; + } +} diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 95e6b590..e2f7dfa8 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -91,6 +91,7 @@ export interface KtxSchemaSnapshot { scope: KtxSchemaScope; tables: KtxSchemaTable[]; metadata: Record; + warnings?: KtxScanWarning[]; } interface KtxCredentialEnvReference { @@ -365,7 +366,8 @@ type KtxScanWarningCode = | 'relationship_llm_proposal_failed' | 'credential_redacted' | 'enrichment_failed' - | 'description_fallback_used'; + | 'description_fallback_used' + | 'constraint_discovery_unauthorized'; export interface KtxScanWarning { code: KtxScanWarningCode;