/** * Custom error types for Brainy operations * Provides better error classification and handling */ export type BrainyErrorType = | 'TIMEOUT' | 'NETWORK' | 'STORAGE' | 'NOT_FOUND' | 'RETRY_EXHAUSTED' | 'VALIDATION' | 'INVALID_QUERY' | 'FIELD_NOT_INDEXED' | 'GRAPH_INDEX_NOT_READY' | 'METADATA_INDEX_NOT_READY' | 'MIGRATION_IN_PROGRESS' /** * Custom error class for Brainy operations * Provides error type classification and retry information */ export class BrainyError extends Error { public readonly type: BrainyErrorType public readonly retryable: boolean public readonly originalError?: Error public readonly attemptNumber?: number public readonly maxRetries?: number constructor( message: string, type: BrainyErrorType, retryable: boolean = false, originalError?: Error, attemptNumber?: number, maxRetries?: number ) { super(message) this.name = 'BrainyError' this.type = type this.retryable = retryable this.originalError = originalError this.attemptNumber = attemptNumber this.maxRetries = maxRetries // Maintain proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, BrainyError) } } /** * Create a timeout error */ static timeout(operation: string, timeoutMs: number, originalError?: Error): BrainyError { return new BrainyError( `Operation '${operation}' timed out after ${timeoutMs}ms`, 'TIMEOUT', true, originalError ) } /** * Create a network error */ static network(message: string, originalError?: Error): BrainyError { return new BrainyError( `Network error: ${message}`, 'NETWORK', true, originalError ) } /** * Create a storage error */ static storage(message: string, originalError?: Error): BrainyError { return new BrainyError( `Storage error: ${message}`, 'STORAGE', true, originalError ) } /** * Create a not found error */ static notFound(resource: string): BrainyError { return new BrainyError( `Resource not found: ${resource}`, 'NOT_FOUND', false ) } /** * Create a retry exhausted error */ static retryExhausted(operation: string, maxRetries: number, lastError?: Error): BrainyError { return new BrainyError( `Operation '${operation}' failed after ${maxRetries} retry attempts`, 'RETRY_EXHAUSTED', false, lastError, maxRetries, maxRetries ) } /** * Create a "field is not indexed" error. Thrown by metadata-index reads * when a `where` clause names a field that has neither a column-store * entry nor a sparse-index entry. Callers in `find()` evaluation catch * this, translate the offending clause to an empty result, and log so * the silent-empty behavior is replaced with a loud one. Use * `brain.explain({ where: {...} })` to discover this before running. */ static fieldNotIndexed(field: string): BrainyError { return new BrainyError( `Field "${field}" is not indexed. find()/where will not match any entities. ` + `Likely causes: (1) the writer registered the field in memory but has not flushed; ` + `(2) the field name is mistyped; (3) no entity has ever held this field. ` + `Run brain.explain({ where: { ${field}: ... } }) for the diagnostic.`, 'FIELD_NOT_INDEXED', false ) } /** * Create a validation error */ static validation(parameter: string, constraint: string, value?: any): BrainyError { return new BrainyError( `Invalid ${parameter}: ${constraint}`, 'VALIDATION', false ) } /** * Check if an error is retryable */ static isRetryable(error: Error): boolean { if (error instanceof BrainyError) { return error.retryable } // Check for common retryable error patterns const message = error.message.toLowerCase() const name = error.name.toLowerCase() // Network-related errors that are typically retryable if ( message.includes('timeout') || message.includes('network') || message.includes('connection') || message.includes('econnreset') || message.includes('enotfound') || message.includes('etimedout') || name.includes('timeout') ) { return true } // AWS SDK specific retryable errors if ( message.includes('throttling') || message.includes('rate limit') || message.includes('service unavailable') || message.includes('internal server error') || message.includes('bad gateway') || message.includes('gateway timeout') ) { return true } return false } /** * Convert a generic error to a BrainyError with appropriate classification */ static fromError(error: Error, operation?: string): BrainyError { if (error instanceof BrainyError) { return error } const message = error.message.toLowerCase() const name = error.name.toLowerCase() // Classify the error based on common patterns if (message.includes('timeout') || name.includes('timeout')) { return BrainyError.timeout(operation || 'unknown', 0, error) } if ( message.includes('network') || message.includes('connection') || message.includes('econnreset') || message.includes('enotfound') || message.includes('etimedout') ) { return BrainyError.network(error.message, error) } if ( message.includes('nosuchkey') || message.includes('not found') || message.includes('does not exist') ) { return BrainyError.notFound(operation || 'resource') } if ( message.includes('invalid') || message.includes('validation') || message.includes('cannot be null') || message.includes('must be') ) { return new BrainyError(error.message, 'VALIDATION', false, error) } // Default to storage error for unclassified errors return BrainyError.storage(error.message, error) } } /** * Thrown when the graph adjacency index reports that relationships exist (its * persisted manifest/count loaded, or its readiness signal says otherwise) but * the source→target adjacency itself did NOT load — so graph traversals * (`find({ connected })`, `neighbors()`, `related()`) would otherwise return an * EMPTY array indistinguishable from "no edges". * * On 8.0 brainy detects this on the first graph read via the provider's honest * sync `isReady()` signal (true ONLY when the edges are loaded; see * {@link import('../plugin.js').GraphIndexProvider.isReady}); for older providers * that do not expose it, it falls back to a known-edge-sample probe (one persisted * verb + one neighbor lookup). Either way it attempts a rebuild from storage and * raises this LOUD, catchable error only if even that cannot make the adjacency * ready — replacing silent data-invisibility with a clear failure. * * Observed with a native graph provider whose cold-open adjacency load is * swallowed on certain storage adapters; the fix is upstream in the provider, * but Brainy refuses to serve `[]` as if it were truth. */ export class GraphIndexNotReadyError extends BrainyError { constructor(message: string, originalError?: Error) { super(message, 'GRAPH_INDEX_NOT_READY', false, originalError) this.name = 'GraphIndexNotReadyError' if (Error.captureStackTrace) { Error.captureStackTrace(this, GraphIndexNotReadyError) } } } /** * Thrown when the metadata field index reports data but cannot serve a KNOWN * persisted field value even after a rebuild — i.e. the `where` / filter * postings did not load on a cold open and could not be restored. The * field-index counterpart of {@link GraphIndexNotReadyError}: it replaces the * silent-empty failure mode (a cold `find({ where })` returning `[]` * indistinguishable from "no such data") with a loud, catchable error, so a * consumer never renders "nothing found" over data that is simply not-yet-warm. * * Detected once per brain by a known-value serving probe on the first filtered * `find()`; brainy self-heals (rebuilds the index from the canonical records) * first and only raises this if the rebuild still cannot serve the known value. */ export class MetadataIndexNotReadyError extends BrainyError { constructor(message: string, originalError?: Error) { super(message, 'METADATA_INDEX_NOT_READY', false, originalError) this.name = 'MetadataIndexNotReadyError' if (Error.captureStackTrace) { Error.captureStackTrace(this, MetadataIndexNotReadyError) } } } /** * Thrown when a data-plane read or write is issued against a brain that is * running its one-time, automatic 7.x → 8.0 on-disk upgrade — the coordinated * migration LOCK. While a native provider rebuilds all derived indexes from the * canonical records, brainy blocks reads and writes so no operation touches a * half-built index; the caller waits for the correct answer rather than getting * a partial one. This error is raised ONLY when the wait exceeds the configured * window ({@link BrainyConfig.migrationWaitTimeoutMs}, default 30 s) — never * instead of a partial/incorrect result. * * It is `retryable`: the upgrade continues in the background. Retry shortly, * watch `brain.getIndexStatus().migration` for progress, or for a very large * brain run the offline migrator. Data is safe; nothing is lost. Consumers can * catch this (e.g. request middleware) and answer HTTP 503 + `Retry-After`. * * @example * try { * await brain.find({ query }) * } catch (e) { * if (e instanceof MigrationInProgressError) { * res.set('Retry-After', '5').status(503).json({ upgrading: true, percent: e.percent }) * return * } * throw e * } */ export class MigrationInProgressError extends BrainyError { /** Milliseconds the operation waited on the migration lock before timing out. */ public readonly elapsedMs: number /** Latest observed migration progress (0–100), when the provider reports it. */ public readonly percent?: number constructor(message: string, elapsedMs: number, percent?: number, originalError?: Error) { super(message, 'MIGRATION_IN_PROGRESS', true, originalError) this.name = 'MigrationInProgressError' this.elapsedMs = elapsedMs this.percent = percent if (Error.captureStackTrace) { Error.captureStackTrace(this, MigrationInProgressError) } } }