474 lines
No EOL
13 KiB
TypeScript
474 lines
No EOL
13 KiB
TypeScript
/**
|
|
* @module utils/typeValidation
|
|
* @description O(1) runtime validation for the {@link NounType} / {@link VerbType}
|
|
* enums. Precomputes a `Set` of each enum's values once at module load, then
|
|
* exposes type-guard predicates (`isValidNounType` / `isValidVerbType`) and their
|
|
* throwing counterparts used by the write-path parameter validators. Pure and
|
|
* side-effect-free apart from the two module-level lookup sets.
|
|
*/
|
|
import { NounType, VerbType } from '../types/graphTypes.js'
|
|
|
|
// Type sets for O(1) validation
|
|
const VALID_NOUN_TYPES = new Set<string>(Object.values(NounType))
|
|
const VALID_VERB_TYPES = new Set<string>(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 Record<string, unknown>
|
|
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 Record<string, unknown>
|
|
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>): 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 Record<string, unknown>
|
|
|
|
// 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++
|
|
}
|
|
} |