import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { URL } from 'node:url'; import type { SqlAnalysisBatchItem, SqlAnalysisBatchResult, SqlAnalysisDialect, SqlAnalysisFingerprintResult, SqlAnalysisLiteralSlot, SqlAnalysisLiteralSlotType, SqlAnalysisPort, SqlReadOnlyValidationResult, } from './ports.js'; export type KtxSqlAnalysisHttpJsonRunner = ( path: string, payload: Record, ) => Promise>; export interface HttpSqlAnalysisPortOptions { baseUrl: string; requestJson?: KtxSqlAnalysisHttpJsonRunner; } function normalizedBaseUrl(baseUrl: string): string { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } function parseJsonObject(raw: string, path: string): Record { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`sql analysis HTTP ${path} returned non-object JSON`); } return parsed as Record; } function postJson(baseUrl: string): KtxSqlAnalysisHttpJsonRunner { return async (path, payload) => new Promise((resolve, reject) => { const target = new URL(path.replace(/^\//, ''), normalizedBaseUrl(baseUrl)); const body = JSON.stringify(payload); const client = target.protocol === 'https:' ? httpsRequest : httpRequest; const request = client( target, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), }, }, (response) => { const chunks: Buffer[] = []; response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); const statusCode = response.statusCode ?? 0; if (statusCode < 200 || statusCode >= 300) { reject(new Error(`sql analysis HTTP ${path} failed with ${statusCode}: ${text}`)); return; } try { resolve(parseJsonObject(text, path)); } catch (error) { reject(error); } }); }, ); request.on('error', reject); request.end(body); }); } function requiredString(raw: Record, field: string): string { const value = raw[field]; if (typeof value !== 'string') { throw new Error(`sql analysis response is missing string field ${field}`); } return value; } function optionalString(raw: Record, field: string): string | null | undefined { const value = raw[field]; if (value === null || value === undefined || typeof value === 'string') { return value; } throw new Error(`sql analysis response has invalid optional string field ${field}`); } function requiredStringArray(raw: Record, field: string): string[] { const value = raw[field]; if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { throw new Error(`sql analysis response is missing string[] field ${field}`); } return value; } function requiredBoolean(raw: Record, field: string): boolean { const value = raw[field]; if (typeof value !== 'boolean') { throw new Error(`sql analysis response is missing boolean field ${field}`); } return value; } function requiredObject(raw: Record, field: string): Record { const value = raw[field]; if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`sql analysis response is missing object field ${field}`); } return value as Record; } function isLiteralSlotType(value: unknown): value is SqlAnalysisLiteralSlotType { return ( value === 'string' || value === 'number' || value === 'timestamp' || value === 'date' || value === 'boolean' || value === 'null' || value === 'unknown' ); } function literalSlots(raw: Record): SqlAnalysisLiteralSlot[] { const value = raw.literal_slots; if (!Array.isArray(value)) { throw new Error('sql analysis response is missing literal_slots array'); } return value.map((item) => { if (!item || typeof item !== 'object' || Array.isArray(item)) { throw new Error('sql analysis response contains invalid literal slot'); } const slot = item as Record; if (typeof slot.position !== 'number') { throw new Error('sql analysis response literal slot is missing numeric position'); } if (!isLiteralSlotType(slot.type)) { throw new Error('sql analysis response literal slot is missing valid type'); } if (typeof slot.example_value !== 'string') { throw new Error('sql analysis response literal slot is missing example_value'); } return { position: slot.position, type: slot.type, exampleValue: slot.example_value, }; }); } function mapResult(raw: Record): SqlAnalysisFingerprintResult { const error = optionalString(raw, 'error'); return { fingerprint: requiredString(raw, 'fingerprint'), normalizedSql: requiredString(raw, 'normalized_sql'), tablesTouched: requiredStringArray(raw, 'tables_touched'), literalSlots: literalSlots(raw), ...(error !== undefined ? { error } : {}), }; } function mapColumnsByClause(raw: Record): SqlAnalysisBatchResult['columnsByClause'] { const value = requiredObject(raw, 'columns_by_clause'); const result: SqlAnalysisBatchResult['columnsByClause'] = {}; for (const [clause, columns] of Object.entries(value)) { if (!Array.isArray(columns) || columns.some((item) => typeof item !== 'string')) { throw new Error(`sql analysis response is missing string[] field columns_by_clause.${clause}`); } result[clause] = columns; } return result; } function mapBatchResult(raw: Record): SqlAnalysisBatchResult { const error = optionalString(raw, 'error'); return { tablesTouched: requiredStringArray(raw, 'tables_touched'), columnsByClause: mapColumnsByClause(raw), ...(error !== undefined ? { error } : {}), }; } function mapBatchResponse(raw: Record): Map { const results = requiredObject(raw, 'results'); return new Map( Object.entries(results).map(([id, value]) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`sql analysis response contains invalid batch result ${id}`); } return [id, mapBatchResult(value as Record)]; }), ); } function mapReadOnlyValidation(raw: Record): SqlReadOnlyValidationResult { const error = optionalString(raw, 'error'); return { ok: requiredBoolean(raw, 'ok'), ...(error !== undefined ? { error } : {}), }; } export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions): SqlAnalysisPort { const requestJson = options.requestJson ?? postJson(options.baseUrl); return { async analyzeForFingerprint(sql: string, dialect: SqlAnalysisDialect) { const raw = await requestJson('/api/sql/analyze-for-fingerprint', { sql, dialect, }); return mapResult(raw); }, async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect) { const raw = await requestJson('/sql/analyze-batch', { dialect, items, }); return mapBatchResponse(raw); }, async validateReadOnly(sql: string, dialect: SqlAnalysisDialect) { const raw = await requestJson('/sql/validate-read-only', { dialect, sql, }); return mapReadOnlyValidation(raw); }, }; }