diff --git a/package-lock.json b/package-lock.json index 4ba3b7eb..59761062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@modelcontextprotocol/sdk": "1.1.1", "apache-arrow": "^21.0.0", "epub": "^1.3.0", - "epub2": "^3.0.2", "html-to-text": "^9.0.5", "ini": "^6.0.0", "minimist": "^1.2.8", @@ -2912,16 +2911,6 @@ "node": ">=12.17" } }, - "node_modules/array-hyper-unique": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/array-hyper-unique/-/array-hyper-unique-2.1.6.tgz", - "integrity": "sha512-BdlHRqjKSYs88WFaVNVEc6Kv8ln/FdzCKPbcDPuWs4/EXkQFhnjc8TyR7hnPxRjcjo5LKOhUMGUWpAqRgeJvpA==", - "license": "ISC", - "dependencies": { - "deep-eql": "= 4.0.0", - "lodash": "^4.17.21" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3025,12 +3014,6 @@ "node": ">= 6" } }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3343,15 +3326,6 @@ "license": "MIT", "optional": true }, - "node_modules/crlf-normalize": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/crlf-normalize/-/crlf-normalize-1.0.20.tgz", - "integrity": "sha512-h/rBerTd3YHQGfv7tNT25mfhWvRq2BBLCZZ80GFarFxf6HQGbpW6iqDL3N+HBLpjLfAdcBXfWAzVlLfHkRUQBQ==", - "license": "ISC", - "dependencies": { - "ts-type": ">=2" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3379,18 +3353,6 @@ "node": ">=0.10.0" } }, - "node_modules/deep-eql": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.0.0.tgz", - "integrity": "sha512-GxJC5MOg2KyQlv6WiUF/VAnMj4MWnYiXo4oLgeptOELVoknyErb4Z8+5F/IM/K4g9/80YzzatxmWcyRwUseH0A==", - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -3796,42 +3758,6 @@ "zipfile": "^0.5.11" } }, - "node_modules/epub2": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/epub2/-/epub2-3.0.2.tgz", - "integrity": "sha512-rhvpt27CV5MZfRetfNtdNwi3XcNg1Am0TwfveJkK8YWeHItHepQ8Js9J06v8XRIjuTrCW/NSGYMTy55Of7BfNQ==", - "license": "ISC", - "dependencies": { - "adm-zip": "^0.5.10", - "array-hyper-unique": "^2.1.4", - "bluebird": "^3.7.2", - "crlf-normalize": "^1.0.19", - "tslib": "^2.6.2", - "xml2js": "^0.6.2" - } - }, - "node_modules/epub2/node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/epub2/node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -5034,12 +4960,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -6893,20 +6813,6 @@ "node": ">=18" } }, - "node_modules/ts-type": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ts-type/-/ts-type-3.0.1.tgz", - "integrity": "sha512-cleRydCkBGBFQ4KAvLH0ARIkciduS745prkGVVxPGvcRGhMMoSJUB7gNR1ByKhFTEYrYRg2CsMRGYnqp+6op+g==", - "license": "ISC", - "dependencies": { - "@types/node": "*", - "tslib": ">=2", - "typedarray-dts": "^1.0.0" - }, - "peerDependencies": { - "ts-toolbelt": "^9.6.0" - } - }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -6963,21 +6869,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/typedarray-dts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typedarray-dts/-/typedarray-dts-1.0.0.tgz", - "integrity": "sha512-Ka0DBegjuV9IPYFT1h0Qqk5U4pccebNIJCGl8C5uU7xtOs+jpJvKGAY4fHGK25hTmXZOEUl9Cnsg5cS6K/b5DA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 5e54f5bc..9570192a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "@modelcontextprotocol/sdk": "1.1.1", "apache-arrow": "^21.0.0", "epub": "^1.3.0", - "epub2": "^3.0.2", "html-to-text": "^9.0.5", "ini": "^6.0.0", "minimist": "^1.2.8", diff --git a/src/domain/exceptions.ts b/src/domain/exceptions.ts deleted file mode 100644 index 4f495653..00000000 --- a/src/domain/exceptions.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Domain exceptions for the Concept-RAG system. - * - * These exceptions represent domain-level errors that occur during - * business logic execution. They should be caught at application boundaries - * (MCP tools, API handlers) and converted to appropriate user-facing messages. - * - * **Design Pattern**: Exception Hierarchy - * - Base exception for all domain errors - * - Specific exceptions for different error types - * - Enables precise error handling and logging - * - * See REFERENCES.md for pattern sources and further reading. - * - * @example - * ```typescript - * try { - * const concept = await conceptRepo.findByName('nonexistent'); - * if (!concept) { - * throw new ConceptNotFoundError('nonexistent'); - * } - * } catch (error) { - * if (error instanceof ConceptNotFoundError) { - * return { error: `Concept "${error.conceptName}" not found` }; - * } - * throw error; // Re-throw unexpected errors - * } - * ``` - */ - -/** - * Base class for all domain exceptions. - * - * Extends Error with additional context for debugging and logging. - */ -export abstract class DomainException extends Error { - /** - * Error code for categorization (e.g., 'CONCEPT_NOT_FOUND') - */ - abstract readonly code: string; - - /** - * Additional context for debugging - */ - public readonly context?: Record; - - constructor(message: string, context?: Record) { - super(message); - this.name = this.constructor.name; - this.context = context; - - // Maintains proper stack trace for where error was thrown (V8 only) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } - - /** - * Convert exception to JSON for logging/serialization - */ - toJSON(): object { - return { - name: this.name, - code: this.code, - message: this.message, - context: this.context, - stack: this.stack - }; - } -} - -/** - * Thrown when a concept cannot be found in the concept table. - * - * **When to use**: - * - ConceptRepository.findByName() returns null - * - Concept search operations fail to find matching concept - * - * **Recovery**: Suggest similar concepts or return empty results - * - * @example - * ```typescript - * const concept = await conceptRepo.findByName('machine-learning'); - * if (!concept) { - * throw new ConceptNotFoundError('machine-learning'); - * } - * ``` - */ -export class ConceptNotFoundError extends DomainException { - readonly code = 'CONCEPT_NOT_FOUND'; - - constructor( - public readonly conceptName: string, - context?: Record - ) { - super( - `Concept "${conceptName}" not found`, - { ...context, conceptName } - ); - } -} - -/** - * Thrown when a concept has invalid or missing embeddings. - * - * **When to use**: - * - Concept embeddings array is empty - * - Embeddings have wrong dimensionality (not 384) - * - Embeddings contain invalid values (NaN, Infinity) - * - * **Recovery**: Cannot perform vector search; suggest database rebuild - * - * @example - * ```typescript - * if (!concept.embeddings || concept.embeddings.length !== 384) { - * throw new InvalidEmbeddingsError(concept.concept, concept.embeddings?.length || 0); - * } - * ``` - */ -export class InvalidEmbeddingsError extends DomainException { - readonly code = 'INVALID_EMBEDDINGS'; - - constructor( - public readonly conceptName: string, - public readonly embeddingDimension: number, - context?: Record - ) { - super( - `Concept "${conceptName}" has invalid embeddings (dimension: ${embeddingDimension}, expected: 384)`, - { ...context, conceptName, embeddingDimension, expectedDimension: 384 } - ); - } -} - -/** - * Thrown when a document/source cannot be found. - * - * **When to use**: - * - Source path doesn't exist in catalog - * - Document has been deleted but referenced in chunks - * - * **Recovery**: Return empty results or suggest valid sources - * - * @example - * ```typescript - * const chunks = await chunkRepo.findBySource('/path/to/doc.pdf', 10); - * if (chunks.length === 0) { - * throw new SourceNotFoundError('/path/to/doc.pdf'); - * } - * ``` - */ -export class SourceNotFoundError extends DomainException { - readonly code = 'SOURCE_NOT_FOUND'; - - constructor( - public readonly sourcePath: string, - context?: Record - ) { - super( - `Source document "${sourcePath}" not found`, - { ...context, sourcePath } - ); - } -} - -/** - * Thrown when a search query is invalid or malformed. - * - * **When to use**: - * - Empty search query - * - Query exceeds maximum length - * - Query contains invalid characters - * - * **Recovery**: Prompt user for valid query - * - * @example - * ```typescript - * if (!query.text || query.text.trim().length === 0) { - * throw new InvalidQueryError('Search query cannot be empty'); - * } - * ``` - */ -export class InvalidQueryError extends DomainException { - readonly code = 'INVALID_QUERY'; - - constructor( - message: string, - public readonly query?: string, - context?: Record - ) { - super(message, { ...context, query }); - } -} - -/** - * Thrown when database operations fail unexpectedly. - * - * **When to use**: - * - LanceDB connection fails - * - Table doesn't exist - * - Query execution fails - * - * **Recovery**: Retry or suggest database verification - * - * @example - * ```typescript - * try { - * const results = await table.vectorSearch(vector).toArray(); - * } catch (error) { - * throw new DatabaseOperationError('Vector search failed', error as Error); - * } - * ``` - */ -export class DatabaseOperationError extends DomainException { - readonly code = 'DATABASE_OPERATION_ERROR'; - - constructor( - message: string, - public readonly originalError?: Error, - context?: Record - ) { - super(message, { - ...context, - originalError: originalError?.message, - originalStack: originalError?.stack - }); - } -} - -/** - * Thrown when schema validation fails (e.g., unexpected field types). - * - * **When to use**: - * - Database row has unexpected structure - * - Field type doesn't match expected type - * - Required field is missing - * - * **Recovery**: Database schema may need rebuild - * - * @example - * ```typescript - * if (typeof row.vector !== 'object' || !Array.isArray(row.vector)) { - * throw new SchemaValidationError('vector', 'array', typeof row.vector); - * } - * ``` - */ -export class SchemaValidationError extends DomainException { - readonly code = 'SCHEMA_VALIDATION_ERROR'; - - constructor( - public readonly fieldName: string, - public readonly expectedType: string, - public readonly actualType: string, - context?: Record - ) { - super( - `Schema validation failed for field "${fieldName}": expected ${expectedType}, got ${actualType}`, - { ...context, fieldName, expectedType, actualType } - ); - } -} - -/** - * Thrown when required parameters are missing. - * - * **When to use**: - * - MCP tool invoked without required parameters - * - Repository method called with invalid arguments - * - * **Recovery**: Prompt user for missing parameters - * - * @example - * ```typescript - * if (!params.concept) { - * throw new MissingParameterError('concept'); - * } - * ``` - */ -export class MissingParameterError extends DomainException { - readonly code = 'MISSING_PARAMETER'; - - constructor( - public readonly parameterName: string, - context?: Record - ) { - super( - `Required parameter "${parameterName}" is missing`, - { ...context, parameterName } - ); - } -} - diff --git a/src/domain/exceptions/__tests__/data-integrity.test.ts b/src/domain/exceptions/__tests__/data-integrity.test.ts new file mode 100644 index 00000000..cd978681 --- /dev/null +++ b/src/domain/exceptions/__tests__/data-integrity.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + ConceptRAGError, + DataIntegrityError, + ConceptNotFoundError, + InvalidEmbeddingsError, + SchemaValidationError +} from '../index.js'; + +describe('Data-integrity exceptions', () => { + describe('ConceptNotFoundError', () => { + it('preserves the conceptName, code, and message', () => { + const error = new ConceptNotFoundError('machine-learning'); + + expect(error.conceptName).toBe('machine-learning'); + expect(error.code).toBe('CONCEPT_NOT_FOUND'); + expect(error.message).toBe('Concept "machine-learning" not found'); + expect(error.context).toMatchObject({ conceptName: 'machine-learning' }); + }); + + it('is a ConceptRAGError and a DataIntegrityError', () => { + const error = new ConceptNotFoundError('x'); + + expect(error).toBeInstanceOf(ConceptRAGError); + expect(error).toBeInstanceOf(DataIntegrityError); + expect(error.name).toBe('ConceptNotFoundError'); + expect(error.timestamp).toBeInstanceOf(Date); + }); + }); + + describe('InvalidEmbeddingsError', () => { + it('preserves conceptName, embeddingDimension, code, and message', () => { + const error = new InvalidEmbeddingsError('graph-theory', 128); + + expect(error.conceptName).toBe('graph-theory'); + expect(error.embeddingDimension).toBe(128); + expect(error.code).toBe('INVALID_EMBEDDINGS'); + expect(error.message).toContain('dimension: 128, expected: 384'); + expect(error.context).toMatchObject({ + conceptName: 'graph-theory', + embeddingDimension: 128, + expectedDimension: 384 + }); + expect(error).toBeInstanceOf(DataIntegrityError); + }); + }); + + describe('SchemaValidationError', () => { + it('preserves fieldName, expectedType, actualType, code, and message', () => { + const error = new SchemaValidationError('vector', 'array', 'string'); + + expect(error.fieldName).toBe('vector'); + expect(error.expectedType).toBe('array'); + expect(error.actualType).toBe('string'); + expect(error.code).toBe('SCHEMA_VALIDATION_ERROR'); + expect(error.message).toBe( + 'Schema validation failed for field "vector": expected array, got string' + ); + expect(error).toBeInstanceOf(DataIntegrityError); + }); + }); +}); diff --git a/src/domain/exceptions/data-integrity.ts b/src/domain/exceptions/data-integrity.ts new file mode 100644 index 00000000..b5f52a00 --- /dev/null +++ b/src/domain/exceptions/data-integrity.ts @@ -0,0 +1,93 @@ +import { ConceptRAGError } from './base.js'; + +/** + * Base class for data-integrity errors. + * + * Raised on the read path when data already stored in the database does not + * match expectations — a concept is missing, its embeddings are malformed, or + * a row's schema is wrong. Distinct from {@link ValidationError} (bad *input* at + * a trust boundary) and {@link DatabaseError} (a failed *operation*): here the + * operation succeeded but the data it returned is not trustworthy. + */ +export abstract class DataIntegrityError extends ConceptRAGError {} + +/** + * Thrown when a concept cannot be found in the concept table. + * + * **Recovery**: Suggest similar concepts or return empty results. + * + * @example + * ```typescript + * const concept = await conceptRepo.findByName('machine-learning'); + * if (!concept) { + * throw new ConceptNotFoundError('machine-learning'); + * } + * ``` + */ +export class ConceptNotFoundError extends DataIntegrityError { + constructor( + public readonly conceptName: string, + context: Record = {} + ) { + super( + `Concept "${conceptName}" not found`, + 'CONCEPT_NOT_FOUND', + { ...context, conceptName } + ); + } +} + +/** + * Thrown when a concept has invalid or missing embeddings (empty, wrong + * dimensionality, or containing NaN/Infinity). + * + * **Recovery**: Cannot perform vector search; suggest database rebuild. + * + * @example + * ```typescript + * if (!concept.embeddings || concept.embeddings.length !== 384) { + * throw new InvalidEmbeddingsError(concept.concept, concept.embeddings?.length || 0); + * } + * ``` + */ +export class InvalidEmbeddingsError extends DataIntegrityError { + constructor( + public readonly conceptName: string, + public readonly embeddingDimension: number, + context: Record = {} + ) { + super( + `Concept "${conceptName}" has invalid embeddings (dimension: ${embeddingDimension}, expected: 384)`, + 'INVALID_EMBEDDINGS', + { ...context, conceptName, embeddingDimension, expectedDimension: 384 } + ); + } +} + +/** + * Thrown when a stored row fails schema validation (unexpected field type or + * missing required field). + * + * **Recovery**: Database schema may need a rebuild. + * + * @example + * ```typescript + * if (typeof row.vector !== 'object' || !Array.isArray(row.vector)) { + * throw new SchemaValidationError('vector', 'array', typeof row.vector); + * } + * ``` + */ +export class SchemaValidationError extends DataIntegrityError { + constructor( + public readonly fieldName: string, + public readonly expectedType: string, + public readonly actualType: string, + context: Record = {} + ) { + super( + `Schema validation failed for field "${fieldName}": expected ${expectedType}, got ${actualType}`, + 'SCHEMA_VALIDATION_ERROR', + { ...context, fieldName, expectedType, actualType } + ); + } +} diff --git a/src/domain/exceptions/index.ts b/src/domain/exceptions/index.ts index 78785590..f45ada57 100644 --- a/src/domain/exceptions/index.ts +++ b/src/domain/exceptions/index.ts @@ -76,3 +76,11 @@ export { DocumentTooLargeError } from './document.js'; +// Data-integrity errors (read path: stored data doesn't match expectations) +export { + DataIntegrityError, + ConceptNotFoundError, + InvalidEmbeddingsError, + SchemaValidationError +} from './data-integrity.js'; + diff --git a/src/domain/functional/README.md b/src/domain/functional/README.md index 0fb59612..5bfae434 100644 --- a/src/domain/functional/README.md +++ b/src/domain/functional/README.md @@ -4,13 +4,12 @@ This module provides functional programming patterns for error handling and null ## Overview -The module implements three core types: +The module implements two core types: 1. **Result** - For operations that can succeed or fail -2. **Either** - For bi-directional choice (left/right) -3. **Option** - For safe nullable handling +2. **Option** - For safe nullable handling -Plus **Railway Oriented Programming** utilities for composing these types. +> Note: `Either` and the Railway Oriented Programming utilities were removed as dead code — no consumers existed outside the module barrel. Reintroduce them when a real caller needs bi-directional `Either` or pipeline composition (`pipe`/`retry`/`firstSuccess`/`validateAll`). ## Installation @@ -18,9 +17,7 @@ The functional types are available in the `src/domain/functional` directory: ```typescript import { Result, Ok, Err } from '../functional/result'; -import { Either, Left, Right } from '../functional/either'; import { Option, Some, None } from '../functional/option'; -import * as Railway from '../functional/railway'; ``` ## Result @@ -86,47 +83,6 @@ const result = await fromPromise(fetch('/api/data')); const transformed = await mapAsync(result, data => data.json()); ``` -## Either - -The Either type represents a value that can be one of two types. By convention, Left is used for errors and Right for success. - -### Basic Usage - -```typescript -import { Either, Left, Right, isRight } from '../functional/either'; - -function parseJSON(json: string): Either { - try { - return Right(JSON.parse(json)); - } catch (error) { - return Left('Invalid JSON'); - } -} - -const result = parseJSON<{name: string}>('{"name":"Alice"}'); -if (isRight(result)) { - console.log(result.value.name); // "Alice" -} -``` - -### Transforming Either - -```typescript -import { map, bimap } from '../functional/either'; - -// Transform right value -const upper = map(parseJSON('{"name":"alice"}'), data => - data.name.toUpperCase() -); - -// Transform both sides -const formatted = bimap( - parseJSON('invalid'), - err => `JSON Error: ${err}`, - data => `Name: ${data.name}` -); -``` - ## Option The Option type represents a value that may or may not exist, providing a type-safe alternative to null/undefined. @@ -164,75 +120,6 @@ const userName = getOrElse(name, 'Anonymous'); const activeUser = filter(findUser(123), user => user.active); ``` -## Railway Oriented Programming - -Railway utilities help you compose Result-returning functions into pipelines that short-circuit on errors. - -### Basic Pipeline - -```typescript -import { pipe } from '../functional/railway'; - -const validateEmail = (email: string): Result => - email.includes('@') ? Ok(email) : Err('Invalid email'); - -const normalizeEmail = (email: string): Result => - Ok(email.toLowerCase()); - -const saveEmail = (email: string): Result => - Ok(undefined); // Save to database - -const processPipeline = pipe( - validateEmail, - normalizeEmail, - saveEmail -); - -const result = processPipeline('user@EXAMPLE.COM'); -// Ok(undefined) - email validated, normalized, and saved -``` - -### Error Handling Patterns - -```typescript -import { retry, firstSuccess, recover } from '../functional/railway'; - -// Retry on failure -const result = await retry( - () => apiCall(), - { maxAttempts: 3, delayMs: 1000 } -); - -// Try multiple strategies -const fallbackResult = await firstSuccess([ - () => primaryService(), - () => secondaryService(), - () => fallbackService() -]); - -// Recover from errors -const recovered = pipe( - riskyOperation, - recover(defaultValue) -)(input); -``` - -### Validation with Error Accumulation - -```typescript -import { validateAll } from '../functional/railway'; - -const isPositive = (x: number): Result => - x > 0 ? Ok(x) : Err('Must be positive'); - -const isLessThan100 = (x: number): Result => - x < 100 ? Ok(x) : Err('Must be less than 100'); - -// Collects all validation errors -const result = validateAll(150, [isPositive, isLessThan100]); -// Err(['Must be less than 100']) -``` - ## When to Use Each Type ### Use Result When: @@ -241,79 +128,14 @@ const result = validateAll(150, [isPositive, isLessThan100]); - You need to compose operations that might fail - You want to avoid exception-based control flow -### Use Either When: -- You need a bi-directional choice between two types -- Both sides have equal semantic importance -- You want more generic handling than Result - ### Use Option When: - Value might be absent (null/undefined) - Absence is not an error, just a missing value - You want type-safe nullable handling -### Use Railway Utilities When: -- Composing multiple operations that return Results -- Building validation pipelines -- Implementing retry/fallback strategies -- Need error accumulation - ## Practical Examples -### Example 1: User Registration with Validation - -```typescript -import { pipe } from '../functional/railway'; - -interface UserInput { - email: string; - password: string; - age: string; -} - -interface User { - id: string; - email: string; - passwordHash: string; - age: number; -} - -const validateEmail = (input: UserInput): Result => - input.email.includes('@') - ? Ok(input) - : Err('Invalid email format'); - -const validatePassword = (input: UserInput): Result => - input.password.length >= 8 - ? Ok(input) - : Err('Password must be at least 8 characters'); - -const parseAge = (input: UserInput): Result => { - const age = parseInt(input.age, 10); - if (isNaN(age) || age < 18) { - return Err('Must be 18 or older'); - } - return Ok({ - id: generateId(), - email: input.email, - passwordHash: hashPassword(input.password), - age - }); -}; - -const registerUser = pipe( - validateEmail, - validatePassword, - parseAge -); - -const result = registerUser({ - email: 'user@example.com', - password: 'secure123', - age: '25' -}); -``` - -### Example 2: Document Processing Pipeline +### Example 1: Document Processing Pipeline ```typescript function processDocument(path: string): Result { @@ -344,7 +166,7 @@ function processDocument(path: string): Result { } ``` -### Example 3: Safe Array Access with Option +### Example 2: Safe Array Access with Option ```typescript function safeGet(arr: T[], index: number): Option { @@ -418,13 +240,10 @@ if (isOk(result)) { See individual module files for complete API documentation: - `result.ts` - Result type and utilities -- `either.ts` - Either type and utilities - `option.ts` - Option type and utilities -- `railway.ts` - Railway Oriented Programming utilities ## Further Reading -- [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/) by Scott Wlaschin - [Rust Result Documentation](https://doc.rust-lang.org/std/result/) - [Functional Programming in TypeScript](https://gcanti.github.io/fp-ts/) diff --git a/src/domain/functional/either.ts b/src/domain/functional/either.ts deleted file mode 100644 index ee4bd331..00000000 --- a/src/domain/functional/either.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Either Type - Bi-directional Choice - * - * Either represents a value that can be one of two types: Left or Right. - * By convention, Left represents failure/error and Right represents success. - * This is similar to Result but more generic - both sides have equal status. - * - * @template L The left type (by convention, error) - * @template R The right type (by convention, success) - */ - -/** - * Either discriminated union - */ -export type Either = - | { readonly tag: 'left'; readonly value: L } - | { readonly tag: 'right'; readonly value: R }; - -/** - * Constructor Functions - */ - -/** - * Create a Left Either - */ -export function Left(value: L): Either { - return { tag: 'left', value }; -} - -/** - * Create a Right Either - */ -export function Right(value: R): Either { - return { tag: 'right', value }; -} - -/** - * Type Guards - */ - -/** - * Check if Either is Left - */ -export function isLeft(either: Either): either is { tag: 'left'; value: L } { - return either.tag === 'left'; -} - -/** - * Check if Either is Right - */ -export function isRight(either: Either): either is { tag: 'right'; value: R } { - return either.tag === 'right'; -} - -/** - * Core Operations - */ - -/** - * Transform the Right value if present - */ -export function map( - either: Either, - fn: (value: R) => U -): Either { - if (isRight(either)) { - return Right(fn(either.value)); - } - // @ts-expect-error - Type narrowing limitation - return either; -} - -/** - * Transform the Left value if present - */ -export function mapLeft( - either: Either, - fn: (value: L) => M -): Either { - if (isLeft(either)) { - return Left(fn(either.value)); - } - // @ts-expect-error - Type narrowing limitation - return either; -} - -/** - * Transform both Left and Right values - */ -export function bimap( - either: Either, - leftFn: (value: L) => M, - rightFn: (value: R) => U -): Either { - if (isLeft(either)) { - return Left(leftFn(either.value)); - } - // @ts-expect-error - Type narrowing limitation - return Right(rightFn(either.value)); -} - -/** - * Transform the Right value where the transformation returns an Either - */ -export function flatMap( - either: Either, - fn: (value: R) => Either -): Either { - if (isRight(either)) { - return fn(either.value); - } - // @ts-expect-error - Type narrowing limitation - return either; -} - -/** - * Pattern matching on Either (fold/match) - */ -export function fold( - either: Either, - onLeft: (value: L) => U, - onRight: (value: R) => U -): U { - if (isLeft(either)) { - return onLeft(either.value); - } - // @ts-expect-error - Type narrowing limitation - return onRight(either.value); -} - -/** - * Get the Right value or provide a default - */ -export function getOrElse(either: Either, defaultValue: R): R { - if (isRight(either)) { - return either.value; - } - return defaultValue; -} - -/** - * Get the Right value or compute it from Left - */ -export function getOrElseL(either: Either, fn: (left: L) => R): R { - if (isRight(either)) { - return either.value; - } - // @ts-expect-error - Type narrowing limitation - return fn(either.value); -} - -/** - * Swap Left and Right - */ -export function swap(either: Either): Either { - if (isLeft(either)) { - return Right(either.value); - } - // @ts-expect-error - Type narrowing limitation - return Left(either.value); -} - -/** - * Execute a side effect if Right - */ -export function tap(either: Either, fn: (value: R) => void): Either { - if (isRight(either)) { - fn(either.value); - } - return either; -} - -/** - * Execute a side effect if Left - */ -export function tapLeft(either: Either, fn: (value: L) => void): Either { - if (isLeft(either)) { - fn(either.value); - } - return either; -} - -/** - * Async Operations - */ - -/** - * Async version of map - */ -export async function mapAsync( - either: Either, - fn: (value: R) => Promise -): Promise> { - if (isRight(either)) { - return Right(await fn(either.value)); - } - // @ts-expect-error - Type narrowing limitation - return either; -} - -/** - * Async version of flatMap - */ -export async function flatMapAsync( - either: Either, - fn: (value: R) => Promise> -): Promise> { - if (isRight(either)) { - return await fn(either.value); - } - // @ts-expect-error - Type narrowing limitation - return either; -} - -/** - * Async version of bimap - */ -export async function bimapAsync( - either: Either, - leftFn: (value: L) => Promise, - rightFn: (value: R) => Promise -): Promise> { - if (isLeft(either)) { - return Left(await leftFn(either.value)); - } - // @ts-expect-error - Type narrowing limitation - return Right(await rightFn(either.value)); -} - -/** - * Conversions - */ - -/** - * Convert to nullable (Right -> value, Left -> null) - */ -export function toNullable(either: Either): R | null { - return isRight(either) ? either.value : null; -} - -/** - * Convert to undefined (Right -> value, Left -> undefined) - */ -export function toUndefined(either: Either): R | undefined { - return isRight(either) ? either.value : undefined; -} - -/** - * Convert from nullable - */ -export function fromNullable(value: R | null | undefined, leftValue: L): Either { - return value != null ? Right(value) : Left(leftValue); -} - -/** - * Try-catch wrapper returning Either - */ -export function tryCatch(fn: () => R, onError: (error: unknown) => L): Either { - try { - return Right(fn()); - } catch (error) { - return Left(onError(error)); - } -} - -/** - * Async try-catch wrapper - */ -export async function tryCatchAsync( - fn: () => Promise, - onError: (error: unknown) => L -): Promise> { - try { - return Right(await fn()); - } catch (error) { - return Left(onError(error)); - } -} - -/** - * Combinators - */ - -/** - * Combine multiple Eithers - all must be Right for result to be Right - * Returns first Left encountered - */ -export function all(eithers: Either[]): Either { - const values: R[] = []; - for (const either of eithers) { - if (isLeft(either)) { - return either; - } - // @ts-expect-error - Type narrowing limitation - values.push(either.value); - } - return Right(values); -} - -/** - * Apply a function wrapped in Either to a value wrapped in Either - */ -export function ap( - eitherFn: Either U>, - either: Either -): Either { - if (isRight(eitherFn) && isRight(either)) { - return Right(eitherFn.value(either.value)); - } - if (isLeft(eitherFn)) { - return eitherFn; - } - // @ts-expect-error - Type narrowing limitation - return either; -} - diff --git a/src/domain/functional/index.ts b/src/domain/functional/index.ts index 5af6893e..29a91c4b 100644 --- a/src/domain/functional/index.ts +++ b/src/domain/functional/index.ts @@ -1,10 +1,12 @@ /** * Functional Programming Types - * + * * This module provides functional programming patterns for error handling * and nullable value handling in TypeScript. */ +// ponytail: Result + Option only — the Either and Railway modules were removed as dead code (no consumers outside this barrel). add when a real caller needs bi-directional Either or Railway-style pipeline composition (pipe/retry/firstSuccess/validateAll) + // Result type - for operations that can succeed or fail // @ts-expect-error - Type narrowing limitation export * as Result from './result.js'; @@ -12,13 +14,6 @@ export * as Result from './result.js'; export type { Result } from './result.js'; export { Ok, Err, isOk, isErr } from './result.js'; -// Either type - for bi-directional choice -// @ts-expect-error - Type narrowing limitation -export * as Either from './either.js'; -// @ts-expect-error - Type narrowing limitation -export type { Either } from './either.js'; -export { Left, Right, isLeft, isRight } from './either.js'; - // Option type - for nullable value handling // @ts-expect-error - Type narrowing limitation export * as Option from './option.js'; @@ -26,26 +21,3 @@ export * as Option from './option.js'; export type { Option } from './option.js'; export { Some, None, isSome, isNone, fromNullable, toNullable, map as mapOption, fold as foldOption, getOrElse } from './option.js'; -// Railway Oriented Programming utilities -export * as Railway from './railway.js'; -export { - pipe, - pipeAsync, - lift, - liftTry, - liftTryAsync, - tee, - teeErr, - validateAll, - retry, - parallel, - parallelAll, - firstSuccess, - when, - branch, - bimap, - recover, - recoverWith, - ensure -} from './railway.js'; - diff --git a/src/domain/functional/railway.ts b/src/domain/functional/railway.ts deleted file mode 100644 index 72106dbb..00000000 --- a/src/domain/functional/railway.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Railway Oriented Programming Utilities - * - * This module provides utilities for composing Result-returning functions - * in a railway-oriented programming style. The railway metaphor visualizes - * two tracks: a success track and a failure track. Operations stay on the - * success track until an error occurs, then switch to the failure track. - */ - -import { Result, Ok, Err, isOk, flatMap } from './result.js'; - -/** - * Compose two Result-returning functions (railway composition) - * The second function only executes if the first succeeds - */ -export function pipe( - fn1: (value: T) => Result -): (value: T) => Result; - -export function pipe( - fn1: (value: T) => Result, - fn2: (value: U) => Result -): (value: T) => Result; - -export function pipe( - fn1: (value: T) => Result, - fn2: (value: U) => Result, - fn3: (value: V) => Result -): (value: T) => Result; - -export function pipe( - fn1: (value: T) => Result, - fn2: (value: U) => Result, - fn3: (value: V) => Result, - fn4: (value: W) => Result -): (value: T) => Result; - -export function pipe( - fn1: (value: T) => Result, - fn2: (value: U) => Result, - fn3: (value: V) => Result, - fn4: (value: W) => Result, - fn5: (value: X) => Result -): (value: T) => Result; - -export function pipe( - ...fns: Array<(value: any) => Result> -): (value: any) => Result { - return (initialValue: any) => { - let result: Result = Ok(initialValue); - for (const fn of fns) { - result = flatMap(result, fn); - if (!isOk(result)) { - return result; - } - } - return result; - }; -} - -/** - * Async version of pipe - */ -export function pipeAsync( - fn1: (value: T) => Promise> -): (value: T) => Promise>; - -export function pipeAsync( - fn1: (value: T) => Promise>, - fn2: (value: U) => Promise> -): (value: T) => Promise>; - -export function pipeAsync( - fn1: (value: T) => Promise>, - fn2: (value: U) => Promise>, - fn3: (value: V) => Promise> -): (value: T) => Promise>; - -export function pipeAsync( - fn1: (value: T) => Promise>, - fn2: (value: U) => Promise>, - fn3: (value: V) => Promise>, - fn4: (value: W) => Promise> -): (value: T) => Promise>; - -export function pipeAsync( - fn1: (value: T) => Promise>, - fn2: (value: U) => Promise>, - fn3: (value: V) => Promise>, - fn4: (value: W) => Promise>, - fn5: (value: X) => Promise> -): (value: T) => Promise>; - -export function pipeAsync( - ...fns: Array<(value: any) => Promise>> -): (value: any) => Promise> { - return async (initialValue: any) => { - let result: Result = Ok(initialValue); - for (const fn of fns) { - if (!isOk(result)) { - return result; - } - result = await fn(result.value); - } - return result; - }; -} - -/** - * Convert a regular function to one that returns a Result - * Useful for adapting existing functions to railway style - */ -export function lift(fn: (value: T) => U): (value: T) => Result { - return (value: T) => Ok(fn(value)); -} - -/** - * Convert a function that might throw to one that returns a Result - */ -export function liftTry( - fn: (value: T) => U, - errorHandler?: (error: unknown) => string -): (value: T) => Result { - return (value: T) => { - try { - return Ok(fn(value)); - } catch (error) { - const errorMessage = errorHandler - ? errorHandler(error) - : error instanceof Error - ? error.message - : String(error); - return Err(errorMessage); - } - }; -} - -/** - * Async version of liftTry - */ -export function liftTryAsync( - fn: (value: T) => Promise, - errorHandler?: (error: unknown) => string -): (value: T) => Promise> { - return async (value: T) => { - try { - return Ok(await fn(value)); - } catch (error) { - const errorMessage = errorHandler - ? errorHandler(error) - : error instanceof Error - ? error.message - : String(error); - return Err(errorMessage); - } - }; -} - -/** - * Apply a side effect function without changing the Result - * Useful for logging in the middle of a pipeline - * Returns a function that takes a value and returns a Result - */ -export function tee( - fn: (value: T) => void -): (value: T) => Result { - return (value: T) => { - fn(value); - return Ok(value); - }; -} - -/** - * Apply a side effect for errors without changing the Result - * Returns a function that takes a value and returns a Result - */ -export function teeErr( - _fn: (error: E) => void -): (value: T) => Result { - return (value: T) => { - // This should never be called in a successful pipeline - // It's designed to be used with flatMap on a Result - return Ok(value); - }; -} - -/** - * Apply a side effect for errors in a Result, preserving the Result - * Use this with map/flatMap on Results - */ -export function tapError( - fn: (error: E) => void -): (result: Result) => Result { - return (result: Result) => { - if (!isOk(result)) { - // @ts-expect-error - Type narrowing limitation - fn(result.error); - } - return result; - }; -} - -/** - * Combine multiple validations, accumulating all errors - * Unlike flatMap which short-circuits, this collects all failures - */ -export function validateAll( - value: T, - validators: Array<(value: T) => Result> -): Result { - const errors: string[] = []; - - for (const validator of validators) { - const result = validator(value); - if (!isOk(result)) { - // @ts-expect-error - Type narrowing limitation - errors.push(result.error); - } - } - - return errors.length === 0 ? Ok(value) : Err(errors); -} - -/** - * Retry a Result-returning function on failure - */ -export async function retry( - fn: () => Promise>, - options: { - maxAttempts: number; - delayMs?: number; - shouldRetry?: (error: E, attempt: number) => boolean; - } -): Promise> { - const { maxAttempts, delayMs = 0, shouldRetry = () => true } = options; - - let lastResult: Result | undefined; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - lastResult = await fn(); - - if (isOk(lastResult)) { - return lastResult; - } - - // @ts-expect-error - Type narrowing limitation - if (attempt < maxAttempts && shouldRetry(lastResult.error, attempt)) { - if (delayMs > 0) { - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } else { - break; - } - } - - return lastResult!; -} - -/** - * Execute multiple Result-returning operations in parallel - * All must succeed for the overall result to succeed - */ -export async function parallel( - fns: Array<() => Promise>> -): Promise> { - const results = await Promise.all(fns.map(fn => fn())); - - const errors: E[] = []; - const values: T[] = []; - - for (const result of results) { - if (isOk(result)) { - values.push(result.value); - } else { - // @ts-expect-error - Type narrowing limitation - errors.push(result.error); - } - } - - return errors.length === 0 ? Ok(values) : Err(errors[0]); -} - -/** - * Execute multiple Result-returning operations in parallel, collecting all errors - */ -export async function parallelAll( - fns: Array<() => Promise>> -): Promise> { - const results = await Promise.all(fns.map(fn => fn())); - - const errors: E[] = []; - const values: T[] = []; - - for (const result of results) { - if (isOk(result)) { - values.push(result.value); - } else { - // @ts-expect-error - Type narrowing limitation - errors.push(result.error); - } - } - - return errors.length === 0 ? Ok(values) : Err(errors); -} - -/** - * Execute Result-returning operations in sequence, stopping at first success - * Useful for fallback strategies - */ -export async function firstSuccess( - fns: Array<() => Promise>> -): Promise> { - const errors: E[] = []; - - for (const fn of fns) { - const result = await fn(); - if (isOk(result)) { - return result; - } - // @ts-expect-error - Type narrowing limitation - errors.push(result.error); - } - - return Err(errors); -} - -/** - * Conditional execution - only execute if predicate passes - */ -export function when( - predicate: (value: T) => boolean, - fn: (value: T) => Result, - errorMessage: E -): (value: T) => Result { - return (value: T) => { - if (predicate(value)) { - return fn(value); - } - return Err(errorMessage); - }; -} - -/** - * Switch between two functions based on a condition - */ -export function branch( - predicate: (value: T) => boolean, - thenFn: (value: T) => Result, - elseFn: (value: T) => Result -): (value: T) => Result { - return (value: T) => { - return predicate(value) ? thenFn(value) : elseFn(value); - }; -} - -/** - * Transform a Result by mapping both success and error paths - */ -export function bimap( - onOk: (value: T) => U, - onErr: (error: E) => F -): (result: Result) => Result { - return (result: Result) => { - if (isOk(result)) { - return Ok(onOk(result.value)); - } - // @ts-expect-error - Type narrowing limitation - return Err(onErr(result.error)); - }; -} - -/** - * Provide a default value to recover from an error - */ -export function recover( - defaultValue: T -): (result: Result) => Result { - return (result: Result) => { - if (isOk(result)) { - return result; - } - return Ok(defaultValue); - }; -} - -/** - * Recover from an error by computing a replacement value - */ -export function recoverWith( - fn: (error: E) => T -): (result: Result) => Result { - return (result: Result) => { - if (isOk(result)) { - return result; - } - // @ts-expect-error - Type narrowing limitation - return Ok(fn(result.error)); - }; -} - -/** - * Execute a cleanup function regardless of success or failure - */ -export function ensure( - cleanup: () => void -): (result: Result) => Result { - return (result: Result) => { - cleanup(); - return result; - }; -} - diff --git a/src/domain/interfaces/repositories/.gitkeep b/src/domain/interfaces/repositories/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/domain/interfaces/services/.gitkeep b/src/domain/interfaces/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/domain/models/.gitkeep b/src/domain/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/domain/models/index.ts b/src/domain/models/index.ts index c04e2e88..2e964b66 100644 --- a/src/domain/models/index.ts +++ b/src/domain/models/index.ts @@ -1,4 +1,3 @@ export * from './chunk.js'; export * from './concept.js'; export * from './search-result.js'; -export * from '../exceptions.js'; diff --git a/src/infrastructure/embeddings/.gitkeep b/src/infrastructure/embeddings/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/infrastructure/lancedb/repositories/.gitkeep b/src/infrastructure/lancedb/repositories/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/infrastructure/lancedb/repositories/lancedb-chunk-repository.ts b/src/infrastructure/lancedb/repositories/lancedb-chunk-repository.ts index 28c602a5..ccb8cc0b 100644 --- a/src/infrastructure/lancedb/repositories/lancedb-chunk-repository.ts +++ b/src/infrastructure/lancedb/repositories/lancedb-chunk-repository.ts @@ -4,8 +4,7 @@ import { ConceptRepository } from '../../../domain/interfaces/repositories/conce import { EmbeddingService } from '../../../domain/interfaces/services/embedding-service.js'; import { HybridSearchService } from '../../../domain/interfaces/services/hybrid-search-service.js'; import { Chunk, SearchQuery, SearchResult } from '../../../domain/models/index.js'; -import { ConceptNotFoundError, InvalidEmbeddingsError } from '../../../domain/exceptions.js'; -import { DatabaseError } from '../../../domain/exceptions/index.js'; +import { ConceptNotFoundError, InvalidEmbeddingsError, DatabaseError } from '../../../domain/exceptions/index.js'; import { parseJsonField } from '../utils/field-parsers.js'; import { validateChunkRow, detectVectorField } from '../utils/schema-validators.js'; import { SearchableCollectionAdapter } from '../searchable-collection-adapter.js'; diff --git a/src/infrastructure/lancedb/repositories/lancedb-concept-repository.ts b/src/infrastructure/lancedb/repositories/lancedb-concept-repository.ts index 0e5b0853..fb7b3368 100644 --- a/src/infrastructure/lancedb/repositories/lancedb-concept-repository.ts +++ b/src/infrastructure/lancedb/repositories/lancedb-concept-repository.ts @@ -1,8 +1,7 @@ import * as lancedb from "@lancedb/lancedb"; import { ConceptRepository, ScoredConcept } from '../../../domain/interfaces/repositories/concept-repository.js'; import { Concept } from '../../../domain/models/index.js'; -import { ConceptNotFoundError, InvalidEmbeddingsError } from '../../../domain/exceptions.js'; -import { DatabaseError } from '../../../domain/exceptions/index.js'; +import { ConceptNotFoundError, InvalidEmbeddingsError, DatabaseError } from '../../../domain/exceptions/index.js'; import { parseJsonField, escapeSqlString } from '../utils/field-parsers.js'; import { validateConceptRow, detectVectorField } from '../utils/schema-validators.js'; import { diff --git a/src/infrastructure/lancedb/utils/schema-validators.ts b/src/infrastructure/lancedb/utils/schema-validators.ts index 76ed07b3..fcf2854e 100644 --- a/src/infrastructure/lancedb/utils/schema-validators.ts +++ b/src/infrastructure/lancedb/utils/schema-validators.ts @@ -13,7 +13,7 @@ * **Safety**: All validators are non-destructive; they only read data. */ -import { SchemaValidationError, InvalidEmbeddingsError } from '../../../domain/exceptions.js'; +import { SchemaValidationError, InvalidEmbeddingsError } from '../../../domain/exceptions/index.js'; /** * Expected embedding dimension for all vectors in the system. diff --git a/src/tools/base/__tests__/tool.test.ts b/src/tools/base/__tests__/tool.test.ts new file mode 100644 index 00000000..5c7b75e9 --- /dev/null +++ b/src/tools/base/__tests__/tool.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { BaseTool, ToolResponse } from '../tool.js'; +import { ConceptNotFoundError } from '../../../domain/exceptions/index.js'; + +/** + * Minimal concrete tool exposing the protected handleError for testing. + */ +class TestTool extends BaseTool { + name = 'test'; + description = 'test tool'; + inputSchema = { type: 'object' as const, properties: {} }; + async execute(): Promise { + return { content: [], isError: false }; + } + handle(error: unknown): ToolResponse { + return this.handleError(error); + } +} + +describe('BaseTool.handleError', () => { + const tool = new TestTool(); + + it('formats a data-integrity error as structured output (code, context, timestamp)', () => { + // Data-integrity errors now extend ConceptRAGError, so the boundary emits + // their structured form rather than a bare message. + const response = tool.handle(new ConceptNotFoundError('machine-learning')); + + expect(response.isError).toBe(true); + expect(response._meta?.errorCode).toBe('CONCEPT_NOT_FOUND'); + expect(response._meta?.errorName).toBe('ConceptNotFoundError'); + + const payload = JSON.parse(response.content[0].text); + expect(payload.error.code).toBe('CONCEPT_NOT_FOUND'); + expect(payload.error.context).toMatchObject({ conceptName: 'machine-learning' }); + expect(payload.error.timestamp).toBeDefined(); + }); + + it('falls back to a plain message for non-ConceptRAGError errors', () => { + const response = tool.handle(new Error('boom')); + + expect(response.isError).toBe(true); + expect(response.content[0].text).toBe('boom'); + expect(response._meta).toBeUndefined(); + }); +});