feat(scan): add constraint discovery warning helper

This commit is contained in:
Andrey Avtomonov 2026-05-24 01:08:15 +02:00
parent 2d9ba2de98
commit 483d0d6c15
3 changed files with 115 additions and 1 deletions

View file

@ -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' },
});
});
});

View file

@ -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<T> = { 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<T>(
ctx: ConstraintQueryContext,
fn: () => Promise<T>,
): Promise<ConstraintQueryOutcome<T>> {
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 }),
};
}
}

View file

@ -91,6 +91,7 @@ export interface KtxSchemaSnapshot {
scope: KtxSchemaScope;
tables: KtxSchemaTable[];
metadata: Record<string, unknown>;
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;