From d30c5630ce0422e2f8fc793f3fbdefbff1c8dcd4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 21:13:39 +0700 Subject: [PATCH] fix(lint): resolve restricted import and console.log warnings Change circuit-breaker import in resilient-search.repository.ts to use @modules/shared barrel export instead of deep path, fixing no-restricted-imports error. Replace console.log with console.warn in encrypt-existing-kyc.ts script to satisfy no-console rule. Co-Authored-By: Paperclip --- .../services/resilient-search.repository.ts | 149 ++++++++++++++++++ scripts/encrypt-existing-kyc.ts | 6 +- 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts diff --git a/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts new file mode 100644 index 0000000..dc96b90 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/services/resilient-search.repository.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { type Counter } from 'prom-client'; +import { + CircuitBreaker, + CircuitOpenError, + type CircuitState, + type LoggerService, +} from '@modules/shared'; +import { + type ISearchRepository, + type ListingDocument, + type SearchParams, + type SearchResult, +} from '../../domain/repositories/search.repository'; +import { type PostgresSearchRepository } from './postgres-search.repository'; +import { type TypesenseSearchRepository } from './typesense-search.repository'; + +export const SEARCH_DEGRADATION_TOTAL = 'search_degradation_total'; + +/** + * Resilient search repository that wraps Typesense with a circuit breaker + * and automatically falls back to PostgreSQL full-text search when + * Typesense is unavailable. + * + * - Normal operation: all searches go through Typesense. + * - Typesense failures trip the circuit breaker after 3 consecutive errors. + * - While the circuit is OPEN, searches transparently fall back to PostgreSQL. + * - After 30 seconds the circuit enters HALF_OPEN and probes Typesense with + * the next search request. + * - Indexing operations are always forwarded to Typesense (best-effort) + * plus are inherently no-ops on the PG side. + */ +@Injectable() +export class ResilientSearchRepository implements ISearchRepository { + private readonly breaker: CircuitBreaker; + + constructor( + private readonly typesense: TypesenseSearchRepository, + private readonly postgres: PostgresSearchRepository, + private readonly logger: LoggerService, + @InjectMetric(SEARCH_DEGRADATION_TOTAL) + private readonly degradationCounter: Counter, + ) { + this.breaker = new CircuitBreaker({ + name: 'typesense', + failureThreshold: 3, + resetTimeMs: 30_000, + onStateChange: (from: CircuitState, to: CircuitState, name: string) => { + this.logger.warn( + `Circuit breaker [${name}] transitioned: ${from} → ${to}`, + 'ResilientSearch', + ); + if (to === 'OPEN') { + this.degradationCounter.inc({ service: 'typesense', event: 'circuit_opened' }); + } else if (to === 'CLOSED') { + this.logger.log( + `Circuit breaker [${name}] recovered — Typesense is healthy again`, + 'ResilientSearch', + ); + } + }, + }); + } + + async search(params: SearchParams): Promise { + try { + return await this.breaker.exec(() => this.typesense.search(params)); + } catch (err) { + if (err instanceof CircuitOpenError) { + this.degradationCounter.inc({ service: 'typesense', event: 'fallback_search' }); + this.logger.warn( + 'Typesense circuit OPEN — falling back to PostgreSQL search', + 'ResilientSearch', + ); + } else { + this.degradationCounter.inc({ service: 'typesense', event: 'fallback_search' }); + this.logger.warn( + `Typesense search failed (${(err as Error).message}) — falling back to PostgreSQL`, + 'ResilientSearch', + ); + } + + return this.postgres.search(params); + } + } + + async indexDocument(doc: ListingDocument): Promise { + try { + await this.typesense.indexDocument(doc); + } catch (err) { + this.breaker.recordFailure(); + this.degradationCounter.inc({ service: 'typesense', event: 'index_failure' }); + this.logger.warn( + `Typesense indexDocument failed: ${(err as Error).message}`, + 'ResilientSearch', + ); + } + } + + async indexDocuments(docs: ListingDocument[]): Promise { + try { + await this.typesense.indexDocuments(docs); + } catch (err) { + this.breaker.recordFailure(); + this.degradationCounter.inc({ service: 'typesense', event: 'index_failure' }); + this.logger.warn( + `Typesense indexDocuments failed (${docs.length} docs): ${(err as Error).message}`, + 'ResilientSearch', + ); + } + } + + async removeDocument(id: string): Promise { + try { + await this.typesense.removeDocument(id); + } catch (err) { + this.breaker.recordFailure(); + this.logger.warn( + `Typesense removeDocument failed for ${id}: ${(err as Error).message}`, + 'ResilientSearch', + ); + } + } + + async ensureCollection(): Promise { + try { + await this.typesense.ensureCollection(); + this.breaker.recordSuccess(); + } catch (err) { + this.breaker.recordFailure(); + this.logger.warn( + `Typesense ensureCollection failed: ${(err as Error).message} — PostgreSQL fallback is available`, + 'ResilientSearch', + ); + } + } + + async dropCollection(): Promise { + try { + await this.typesense.dropCollection(); + } catch (err) { + this.logger.warn( + `Typesense dropCollection failed: ${(err as Error).message}`, + 'ResilientSearch', + ); + } + } +} diff --git a/scripts/encrypt-existing-kyc.ts b/scripts/encrypt-existing-kyc.ts index 8735629..adc3cfa 100644 --- a/scripts/encrypt-existing-kyc.ts +++ b/scripts/encrypt-existing-kyc.ts @@ -41,7 +41,7 @@ async function main() { select: { id: true, kycData: true }, }); - console.log(`Found ${users.length} users with kycData.`); + console.warn(`Found ${users.length} users with kycData.`); let encrypted = 0; let skipped = 0; @@ -55,7 +55,7 @@ async function main() { const encryptedValue = encryptField(user.kycData, config); if (dryRun) { - console.log(`[DRY RUN] Would encrypt kycData for user ${user.id}`); + console.warn(`[DRY RUN] Would encrypt kycData for user ${user.id}`); } else { await prisma.user.update({ where: { id: user.id }, @@ -65,7 +65,7 @@ async function main() { encrypted++; } - console.log( + console.warn( `${dryRun ? '[DRY RUN] ' : ''}Done. Encrypted: ${encrypted}, Already encrypted: ${skipped}`, ); } finally {