import { NounType, VerbType } from '../types/graphTypes.js' // Type sets for O(1) validation const VALID_NOUN_TYPES = new Set(Object.values(NounType)) const VALID_VERB_TYPES = new Set(Object.values(VerbType)) // Type guards export function isValidNounType(type: unknown): type is NounType { return typeof type === 'string' && VALID_NOUN_TYPES.has(type as string) } export function isValidVerbType(type: unknown): type is VerbType { return typeof type === 'string' && VALID_VERB_TYPES.has(type as string) } // Validators with helpful errors export function validateNounType(type: unknown): NounType { if (!isValidNounType(type)) { const suggestion = findClosestMatch(String(type), VALID_NOUN_TYPES) throw new Error( `Invalid noun type: '${type}'. ${suggestion ? `Did you mean '${suggestion}'?` : ''} ` + `Valid types are: ${[...VALID_NOUN_TYPES].sort().join(', ')}` ) } return type } export function validateVerbType(type: unknown): VerbType { if (!isValidVerbType(type)) { const suggestion = findClosestMatch(String(type), VALID_VERB_TYPES) throw new Error( `Invalid verb type: '${type}'. ${suggestion ? `Did you mean '${suggestion}'?` : ''} ` + `Valid types are: ${[...VALID_VERB_TYPES].sort().join(', ')}` ) } return type } // Graph entity validators export interface ValidatedGraphNoun { noun: NounType [key: string]: any } export interface ValidatedGraphVerb { verb: VerbType [key: string]: any } export function validateGraphNoun(noun: unknown): ValidatedGraphNoun { if (!noun || typeof noun !== 'object') { throw new Error('Invalid noun: must be an object') } const n = noun as any if (!n.noun) { throw new Error('Invalid noun: missing required "noun" type field') } n.noun = validateNounType(n.noun) return n as ValidatedGraphNoun } export function validateGraphVerb(verb: unknown): ValidatedGraphVerb { if (!verb || typeof verb !== 'object') { throw new Error('Invalid verb: must be an object') } const v = verb as any if (!v.verb) { throw new Error('Invalid verb: missing required "verb" type field') } v.verb = validateVerbType(v.verb) return v as ValidatedGraphVerb } // Helper for suggestions using Levenshtein distance function findClosestMatch(input: string, validSet: Set): string | null { if (!input) return null const lower = input.toLowerCase() let bestMatch: string | null = null let bestScore = Infinity for (const valid of validSet) { const validLower = valid.toLowerCase() // Exact match (case-insensitive) if (validLower === lower) { return valid } // Substring match if (validLower.includes(lower) || lower.includes(validLower)) { return valid } // Calculate Levenshtein distance const distance = levenshteinDistance(lower, validLower) if (distance < bestScore && distance <= 3) { // Threshold of 3 for suggestions bestScore = distance bestMatch = valid } } return bestMatch } // Levenshtein distance implementation function levenshteinDistance(str1: string, str2: string): number { const m = str1.length const n = str2.length const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)) for (let i = 0; i <= m; i++) { dp[i][0] = i } for (let j = 0; j <= n; j++) { dp[0][j] = j } for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (str1[i - 1] === str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] } else { dp[i][j] = 1 + Math.min( dp[i - 1][j], // deletion dp[i][j - 1], // insertion dp[i - 1][j - 1] // substitution ) } } } return dp[m][n] } // Batch validation helpers export function validateNounTypes(types: unknown[]): NounType[] { return types.map(validateNounType) } export function validateVerbTypes(types: unknown[]): VerbType[] { return types.map(validateVerbType) } // Export validation statistics for monitoring export interface ValidationStats { validated: number failed: number inferred: number suggestions: number } let stats: ValidationStats = { validated: 0, failed: 0, inferred: 0, suggestions: 0 } export function getValidationStats(): ValidationStats { return { ...stats } } export function resetValidationStats(): void { stats = { validated: 0, failed: 0, inferred: 0, suggestions: 0 } } // ================================================================ // INPUT VALIDATION UTILITIES // ================================================================ // Comprehensive validation for all public API parameters // Extends the existing type validation system export class ValidationError extends Error { constructor( public readonly parameter: string, public readonly value: any, public readonly constraint: string ) { super(`Invalid ${parameter}: ${constraint}`) this.name = 'ValidationError' } } /** * Validate required ID parameter * Standard validation for all ID-based operations */ export function validateId(id: unknown, paramName = 'id'): string { if (id === null || id === undefined) { throw new ValidationError(paramName, id, 'cannot be null or undefined') } if (typeof id !== 'string') { throw new ValidationError(paramName, id, 'must be a string') } if (id.trim().length === 0) { throw new ValidationError(paramName, id, 'cannot be empty string') } if (id.length > 512) { throw new ValidationError(paramName, id, 'cannot exceed 512 characters') } return id.trim() } /** * Validate search query input * Handles string queries, vectors, and objects for search operations */ export function validateSearchQuery(query: unknown, paramName = 'query'): any { if (query === null || query === undefined) { throw new ValidationError(paramName, query, 'cannot be null or undefined') } // Allow strings, arrays (vectors), or objects if (typeof query === 'string') { if (query.trim().length === 0) { throw new ValidationError(paramName, query, 'query string cannot be empty') } if (query.length > 10000) { throw new ValidationError(paramName, query, 'query string too long (max 10000 characters)') } return query.trim() } if (Array.isArray(query)) { if (query.length === 0) { throw new ValidationError(paramName, query, 'array cannot be empty') } // Validate vector arrays contain only numbers if (query.every(item => typeof item === 'number')) { if (query.some(num => !isFinite(num))) { throw new ValidationError(paramName, query, 'vector contains invalid numbers (NaN or Infinity)') } } return query } if (typeof query === 'object') { return query } throw new ValidationError(paramName, query, 'must be string, array, or object') } /** * Validate data input for addNoun/updateNoun operations * Handles vectors, objects, strings, and validates structure */ export function validateDataInput(data: unknown, paramName = 'data', allowNull = false): any { // Handle null/undefined if (data === null) { if (!allowNull) { throw new ValidationError(paramName, data, 'Input cannot be null or undefined') } return data } if (data === undefined) { throw new ValidationError(paramName, data, 'Input cannot be null or undefined') } // Handle strings (including empty strings which are valid) if (typeof data === 'string') { // Empty strings are valid - they get converted to embeddings // This matches the behavior in the embed function if (data.length > 1000000) { throw new ValidationError(paramName, data, 'string too long (max 1MB)') } return data } // Handle arrays (vectors) if (Array.isArray(data)) { if (data.length === 0) { throw new ValidationError(paramName, data, 'array cannot be empty') } if (data.length > 100000) { throw new ValidationError(paramName, data, 'array too large (max 100k elements)') } // Validate vector arrays contain only numbers if (data.every(item => typeof item === 'number')) { if (data.some(num => !isFinite(num))) { throw new ValidationError(paramName, data, 'vector contains invalid numbers (NaN or Infinity)') } } return data } // Handle objects if (typeof data === 'object') { try { // Quick check if object can be serialized (avoids circular references) JSON.stringify(data) } catch (error) { throw new ValidationError(paramName, data, 'object contains circular references or unserializable values') } return data } // Handle primitive types if (typeof data === 'number') { if (!isFinite(data)) { throw new ValidationError(paramName, data, 'number must be finite (not NaN or Infinity)') } return data } if (typeof data === 'boolean') { return data } throw new ValidationError(paramName, data, 'must be string, number, boolean, array, or object') } /** * Validate search options * Comprehensive validation for search API options */ export function validateSearchOptions(options: unknown, paramName = 'options'): any { if (options === null || options === undefined) { return {} // Default to empty options } if (typeof options !== 'object' || Array.isArray(options)) { throw new ValidationError(paramName, options, 'must be an object') } const opts = options as any // Validate limit if ('limit' in opts) { const limit = opts.limit if (typeof limit !== 'number' || limit < 1 || limit > 10000 || !Number.isInteger(limit)) { throw new ValidationError(`${paramName}.limit`, limit, 'must be integer between 1 and 10000') } } // Validate offset if ('offset' in opts) { const offset = opts.offset if (typeof offset !== 'number' || offset < 0 || !Number.isInteger(offset)) { throw new ValidationError(`${paramName}.offset`, offset, 'must be non-negative integer') } } // Validate threshold if ('threshold' in opts) { const threshold = opts.threshold if (typeof threshold !== 'number' || threshold < 0 || threshold > 1) { throw new ValidationError(`${paramName}.threshold`, threshold, 'must be number between 0 and 1') } } // Validate timeout if ('timeout' in opts) { const timeout = opts.timeout if (typeof timeout !== 'number' || timeout < 1 || timeout > 300000 || !Number.isInteger(timeout)) { throw new ValidationError(`${paramName}.timeout`, timeout, 'must be integer between 1 and 300000 milliseconds') } } // Validate nounTypes array if ('nounTypes' in opts) { if (!Array.isArray(opts.nounTypes)) { throw new ValidationError(`${paramName}.nounTypes`, opts.nounTypes, 'must be an array') } if (opts.nounTypes.length > 100) { throw new ValidationError(`${paramName}.nounTypes`, opts.nounTypes, 'too many noun types (max 100)') } // Validate each noun type opts.nounTypes = opts.nounTypes.map((type: unknown, index: number) => { try { return validateNounType(type) } catch (error) { if (error instanceof Error) { throw new ValidationError(`${paramName}.nounTypes[${index}]`, type, error.message) } throw error } }) } // Validate itemIds array if ('itemIds' in opts) { if (!Array.isArray(opts.itemIds)) { throw new ValidationError(`${paramName}.itemIds`, opts.itemIds, 'must be an array') } if (opts.itemIds.length > 10000) { throw new ValidationError(`${paramName}.itemIds`, opts.itemIds, 'too many item IDs (max 10000)') } opts.itemIds = opts.itemIds.map((id: unknown, index: number) => { try { return validateId(id, `${paramName}.itemIds[${index}]`) } catch (error) { throw error // Re-throw with proper context } }) } return opts } /** * Validate ID arrays (for bulk operations) */ export function validateIdArray(ids: unknown, paramName = 'ids'): string[] { if (ids === null || ids === undefined) { throw new ValidationError(paramName, ids, 'cannot be null or undefined') } if (!Array.isArray(ids)) { throw new ValidationError(paramName, ids, 'must be an array') } if (ids.length === 0) { throw new ValidationError(paramName, ids, 'cannot be empty') } if (ids.length > 10000) { throw new ValidationError(paramName, ids, 'too large (max 10000 items)') } return ids.map((id, index) => { try { return validateId(id, `${paramName}[${index}]`) } catch (error) { throw error // Re-throw with proper array context } }) } /** * Track validation stats for monitoring */ export function recordValidation(success: boolean): void { if (success) { stats.validated++ } else { stats.failed++ } }