@open-mercato/search 0.6.6-develop.5651.1.c43359070c → 0.6.6-develop.5672.1.11e27afad2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.js CHANGED
@@ -116,18 +116,10 @@ class SearchService {
116
116
  const results = await Promise.allSettled(
117
117
  strategies.map((strategy) => this.executeStrategyIndex(strategy, record))
118
118
  );
119
- for (let i = 0; i < results.length; i++) {
120
- const result = results[i];
121
- if (result.status === "rejected") {
122
- const strategy = strategies[i];
123
- searchError("SearchService", "Strategy index failed", {
124
- strategyId: strategy?.id,
125
- entityId: record.entityId,
126
- recordId: record.recordId,
127
- error: result.reason instanceof Error ? result.reason.message : result.reason
128
- });
129
- }
130
- }
119
+ this.throwOnStrategyFailures("index", strategies, results, {
120
+ entityId: record.entityId,
121
+ recordId: record.recordId
122
+ });
131
123
  }
132
124
  /**
133
125
  * Delete a record from all strategies.
@@ -141,18 +133,7 @@ class SearchService {
141
133
  const results = await Promise.allSettled(
142
134
  strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId))
143
135
  );
144
- for (let i = 0; i < results.length; i++) {
145
- const result = results[i];
146
- if (result.status === "rejected") {
147
- const strategy = strategies[i];
148
- searchError("SearchService", "Strategy delete failed", {
149
- strategyId: strategy?.id,
150
- entityId,
151
- recordId,
152
- error: result.reason instanceof Error ? result.reason.message : result.reason
153
- });
154
- }
155
- }
136
+ this.throwOnStrategyFailures("delete", strategies, results, { entityId, recordId });
156
137
  }
157
138
  /**
158
139
  * Bulk index multiple records.
@@ -209,17 +190,42 @@ class SearchService {
209
190
  return Promise.resolve();
210
191
  })
211
192
  );
193
+ this.throwOnStrategyFailures("purge", strategies, results, { entityId });
194
+ }
195
+ /**
196
+ * Inspect the settled results of a per-strategy write operation, log every
197
+ * rejection, and re-throw an aggregated error when any strategy failed.
198
+ *
199
+ * Write operations (index/delete/purge) must surface failures to the caller
200
+ * so the queue worker re-throws and the job is retried. Swallowing rejections
201
+ * here causes silent, permanent index gaps on transient failures such as
202
+ * Postgres connection-pool exhaustion (issue #3103). Successful strategies
203
+ * still commit their work; only the aggregated failure propagates.
204
+ */
205
+ throwOnStrategyFailures(operation, strategies, results, context) {
206
+ const failures = [];
212
207
  for (let i = 0; i < results.length; i++) {
213
208
  const result = results[i];
214
209
  if (result.status === "rejected") {
215
210
  const strategy = strategies[i];
216
- searchError("SearchService", "Strategy purge failed", {
211
+ failures.push({ strategyId: strategy?.id || "unknown", reason: result.reason });
212
+ searchError("SearchService", `Strategy ${operation} failed`, {
217
213
  strategyId: strategy?.id,
218
- entityId,
214
+ entityId: context.entityId,
215
+ recordId: context.recordId,
219
216
  error: result.reason instanceof Error ? result.reason.message : result.reason
220
217
  });
221
218
  }
222
219
  }
220
+ if (failures.length === 0) return;
221
+ const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures.map((failure) => {
222
+ const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason);
223
+ return `${failure.strategyId} (${message})`;
224
+ }).join(", ")}`;
225
+ throw new AggregateError(
226
+ failures.map((failure) => failure.reason),
227
+ summary
228
+ );
223
229
  }
224
230
  /**
225
231
  * Register a new strategy at runtime.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/service.ts"],
4
- "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n this.throwOnStrategyFailures('index', strategies, results, {\n entityId: record.entityId,\n recordId: record.recordId,\n })\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n this.throwOnStrategyFailures('purge', strategies, results, { entityId })\n }\n\n /**\n * Inspect the settled results of a per-strategy write operation, log every\n * rejection, and re-throw an aggregated error when any strategy failed.\n *\n * Write operations (index/delete/purge) must surface failures to the caller\n * so the queue worker re-throws and the job is retried. Swallowing rejections\n * here causes silent, permanent index gaps on transient failures such as\n * Postgres connection-pool exhaustion (issue #3103). Successful strategies\n * still commit their work; only the aggregated failure propagates.\n */\n private throwOnStrategyFailures(\n operation: 'index' | 'delete' | 'purge',\n strategies: SearchStrategy[],\n results: PromiseSettledResult<unknown>[],\n context: { entityId: string; recordId?: string },\n ): void {\n const failures: Array<{ strategyId: string; reason: unknown }> = []\n\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })\n searchError('SearchService', `Strategy ${operation} failed`, {\n strategyId: strategy?.id,\n entityId: context.entityId,\n recordId: context.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n if (failures.length === 0) return\n\n const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures\n .map((failure) => {\n const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)\n return `${failure.strategyId} (${message})`\n })\n .join(', ')}`\n\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n summary,\n )\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
+ "mappings": "AAUA,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAC5B,SAAS,mCAAmC;AAK5C,MAAM,uBAA0C;AAAA,EAC9C,mBAAmB;AACrB;AAOA,MAAM,qCAAqC;AAE3C,SAAS,4BAA4B,SAAyC;AAC5E,QAAM,SAAS,OAAO,QAAQ,mBAAmB,WAAW,QAAQ,eAAe,KAAK,IAAI;AAC5F,MAAI,OAAQ,QAAO,CAAC,MAAM;AAC1B,MAAI,CAAC,MAAM,QAAQ,QAAQ,eAAe,EAAG,QAAO;AAEpD,QAAM,SAAS,MAAM,KAAK,IAAI;AAAA,IAC5B,QAAQ,gBACL,IAAI,CAAC,UAAW,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI,EAAG,EAC9D,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAAA,EACvC,CAAC;AACD,SAAO;AACT;AAEA,SAAS,iCAAiC,SAAyB,SAAwC;AACzG,QAAM,kBAAkB,4BAA4B,OAAO;AAC3D,MAAI,CAAC,gBAAiB,QAAO;AAC7B,MAAI,gBAAgB,WAAW,EAAG,QAAO,CAAC;AAE1C,QAAM,UAAU,IAAI,IAAI,eAAe;AACvC,SAAO,QAAQ,OAAO,CAAC,WAAW;AAChC,UAAM,iBAAiB,OAAO,OAAO,mBAAmB,WAAW,OAAO,eAAe,KAAK,IAAI;AAClG,WAAO,eAAe,SAAS,KAAK,QAAQ,IAAI,cAAc;AAAA,EAChE,CAAC;AACH;AA0BO,MAAM,cAAc;AAAA,EAUzB,YAAY,UAAgC,CAAC,GAAG;AAJhD,SAAiB,oBAAoB,oBAAI,IAA6D;AACtG,SAAiB,uBAAuB,oBAAI,IAAwC;AAIlF,SAAK,aAAa,oBAAI,IAAI;AAC1B,eAAW,YAAY,QAAQ,cAAc,CAAC,GAAG;AAC/C,WAAK,WAAW,IAAI,SAAS,IAAI,QAAQ;AAAA,IAC3C;AACA,SAAK,oBAAoB,QAAQ,qBAAqB,CAAC,QAAQ;AAC/D,SAAK,mBAAmB,QAAQ;AAChC,SAAK,cAAc,QAAQ,eAAe;AAC1C,SAAK,oBAAoB,QAAQ;AACjC,SAAK,yBAAyB,QAAQ,0BAA0B;AAAA,EAClE;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAkC;AAChC,WAAO,MAAM,KAAK,KAAK,WAAW,OAAO,CAAC;AAAA,EAC5C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,OAAO,OAAe,SAAiD;AAC3E,UAAM,kBAAkB,4BAA4B,OAAO;AAC3D,QAAI,mBAAmB,gBAAgB,WAAW,GAAG;AACnD,aAAO,CAAC;AAAA,IACV;AAEA,UAAM,cAAc,QAAQ,cAAc,KAAK;AAC/C,UAAM,mBAAmB,MAAM,KAAK,uBAAuB,WAAW;AAEtE,QAAI,iBAAiB,WAAW,GAAG;AAEjC,UAAI,KAAK,kBAAkB;AACzB,cAAM,WAAW,MAAM,KAAK,uBAAuB,CAAC,KAAK,gBAAgB,CAAC;AAC1E,YAAI,SAAS,SAAS,GAAG;AACvB,2BAAiB,KAAK,GAAG,QAAQ;AAAA,QACnC;AAAA,MACF;AAAA,IACF;AAEA,QAAI,iBAAiB,WAAW,GAAG;AACjC,aAAO,CAAC;AAAA,IACV;AAGA,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,iBAAiB,IAAI,CAAC,aAAa,KAAK,sBAAsB,UAAU,OAAO,OAAO,CAAC;AAAA,IACzF;AAGA,UAAM,aAA6B,CAAC;AACpC,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,OAAO,WAAW,aAAa;AACjC,mBAAW,KAAK,GAAG,OAAO,KAAK;AAAA,MACjC,OAAO;AACL,cAAM,WAAW,iBAAiB,CAAC;AACnC,oBAAY,iBAAiB,0BAA0B;AAAA,UACrD,YAAY,UAAU;AAAA,UACtB,OAAO,OAAO,kBAAkB,QAAQ,OAAO,OAAO,UAAU,OAAO;AAAA,QACzE,CAAC;AAAA,MACH;AAAA,IACF;AAGA,UAAM,SAAS,oBAAoB,YAAY,KAAK,WAAW;AAC/D,UAAM,SAAS,iCAAiC,QAAQ,OAAO;AAG/D,WAAO,KAAK,2BAA2B,QAAQ,QAAQ,UAAU,QAAQ,cAAc;AAAA,EACzF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,2BACZ,SACA,UACA,gBACyB;AAEzB,QAAI,CAAC,KAAK,kBAAmB,QAAO;AAEpC,UAAM,aAAa,QAAQ,KAAK,2BAA2B;AAC3D,QAAI,CAAC,WAAY,QAAO;AAGxB,QAAI;AACF,aAAO,MAAM,KAAK,kBAAkB,SAAS,UAAU,cAAc;AAAA,IACvE,QAAQ;AAEN,aAAO;AAAA,IACT;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,MAAM,QAAwC;AAClD,UAAM,aAAa,MAAM,KAAK,uBAAuB;AAErD,QAAI,WAAW,WAAW,GAAG;AAC3B;AAAA,IACF;AAEA,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,WAAW,IAAI,CAAC,aAAa,KAAK,qBAAqB,UAAU,MAAM,CAAC;AAAA,IAC1E;AAEA,SAAK,wBAAwB,SAAS,YAAY,SAAS;AAAA,MACzD,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO;AAAA,IACnB,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,OAAO,UAAkB,UAAkB,UAAiC;AAChF,UAAM,aAAa,MAAM,KAAK,uBAAuB;AAErD,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,WAAW,IAAI,CAAC,aAAa,KAAK,sBAAsB,UAAU,UAAU,UAAU,QAAQ,CAAC;AAAA,IACjG;AAEA,SAAK,wBAAwB,UAAU,YAAY,SAAS,EAAE,UAAU,SAAS,CAAC;AAAA,EACpF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,UAAU,SAA2C;AACzD,QAAI,QAAQ,WAAW,EAAG;AAE1B,UAAM,aAAa,MAAM,KAAK,uBAAuB;AAErD,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,WAAW,IAAI,CAAC,aAAa;AAC3B,YAAI,SAAS,WAAW;AACtB,iBAAO,SAAS,UAAU,OAAO;AAAA,QACnC;AAEA,eAAO,QAAQ,IAAI,QAAQ,IAAI,CAAC,WAAW,KAAK,qBAAqB,UAAU,MAAM,CAAC,CAAC;AAAA,MACzF,CAAC;AAAA,IACH;AAGA,UAAM,WAAyD,CAAC;AAChE,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,OAAO,WAAW,YAAY;AAChC,cAAM,WAAW,WAAW,CAAC;AAC7B,cAAM,eAAe,OAAO,kBAAkB,QAAQ,OAAO,OAAO,UAAU,OAAO;AACrF,iBAAS,KAAK;AAAA,UACZ,YAAY,UAAU,MAAM;AAAA,UAC5B,OAAO;AAAA,QACT,CAAC;AACD,oBAAY,iBAAiB,6BAA6B;AAAA,UACxD,YAAY,UAAU;AAAA,UACtB,aAAa,QAAQ;AAAA,UACrB,OAAO;AAAA,QACT,CAAC;AAAA,MACH;AAAA,IACF;AAEA,QAAI,SAAS,SAAS,GAAG;AACvB,YAAM,IAAI;AAAA,QACR,4BAA4B,SAAS,MAAM,mBAAmB,SAC3D,IAAI,CAAC,MAAM,GAAG,EAAE,UAAU,KAAK,EAAE,KAAK,GAAG,EACzC,KAAK,IAAI,CAAC;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,MAAM,UAAkB,UAAkB,gBAA+C;AAC7F,UAAM,aAAa,MAAM,KAAK,uBAAuB;AAErD,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC5B,WAAW,IAAI,CAAC,aAAa;AAC3B,YAAI,SAAS,OAAO;AAClB,iBAAO,SAAS,MAAM,UAAU,UAAU,cAAc;AAAA,QAC1D;AACA,eAAO,QAAQ,QAAQ;AAAA,MACzB,CAAC;AAAA,IACH;AAEA,SAAK,wBAAwB,SAAS,YAAY,SAAS,EAAE,SAAS,CAAC;AAAA,EACzE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYQ,wBACN,WACA,YACA,SACA,SACM;AACN,UAAM,WAA2D,CAAC;AAElE,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,OAAO,WAAW,YAAY;AAChC,cAAM,WAAW,WAAW,CAAC;AAC7B,iBAAS,KAAK,EAAE,YAAY,UAAU,MAAM,WAAW,QAAQ,OAAO,OAAO,CAAC;AAC9E,oBAAY,iBAAiB,YAAY,SAAS,WAAW;AAAA,UAC3D,YAAY,UAAU;AAAA,UACtB,UAAU,QAAQ;AAAA,UAClB,UAAU,QAAQ;AAAA,UAClB,OAAO,OAAO,kBAAkB,QAAQ,OAAO,OAAO,UAAU,OAAO;AAAA,QACzE,CAAC;AAAA,MACH;AAAA,IACF;AAEA,QAAI,SAAS,WAAW,EAAG;AAE3B,UAAM,UAAU,UAAU,SAAS,eAAe,SAAS,MAAM,mBAAmB,SACjF,IAAI,CAAC,YAAY;AAChB,YAAM,UAAU,QAAQ,kBAAkB,QAAQ,QAAQ,OAAO,UAAU,OAAO,QAAQ,MAAM;AAChG,aAAO,GAAG,QAAQ,UAAU,KAAK,OAAO;AAAA,IAC1C,CAAC,EACA,KAAK,IAAI,CAAC;AAEb,UAAM,IAAI;AAAA,MACR,SAAS,IAAI,CAAC,YAAY,QAAQ,MAAM;AAAA,MACxC;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,iBAAiB,UAAgC;AAC/C,SAAK,WAAW,IAAI,SAAS,IAAI,QAAQ;AACzC,SAAK,kBAAkB,OAAO,SAAS,EAAE;AACzC,SAAK,qBAAqB,OAAO,SAAS,EAAE;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,mBAAmB,YAAoC;AACrD,SAAK,WAAW,OAAO,UAAU;AACjC,SAAK,kBAAkB,OAAO,UAAU;AACxC,SAAK,qBAAqB,OAAO,UAAU;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,4BAA4B,YAAqC;AAC/D,QAAI,YAAY;AACd,WAAK,kBAAkB,OAAO,UAAU;AACxC,WAAK,qBAAqB,OAAO,UAAU;AAC3C;AAAA,IACF;AACA,SAAK,kBAAkB,MAAM;AAC7B,SAAK,qBAAqB,MAAM;AAAA,EAClC;AAAA;AAAA;AAAA;AAAA,EAKA,0BAA8C;AAC5C,WAAO,MAAM,KAAK,KAAK,WAAW,KAAK,CAAC;AAAA,EAC1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,YAAY,YAA0D;AACpE,WAAO,KAAK,WAAW,IAAI,UAAU;AAAA,EACvC;AAAA;AAAA;AAAA;AAAA,EAKA,uBAA2C;AACzC,WAAO,CAAC,GAAG,KAAK,iBAAiB;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,oBAAoB,YAAgD;AACxE,UAAM,WAAW,KAAK,WAAW,IAAI,UAAU;AAC/C,QAAI,CAAC,SAAU,QAAO;AACtB,WAAO,KAAK,0BAA0B,QAAQ;AAAA,EAChD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,0BAA0B,UAA4C;AAClF,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,SAAS,KAAK,kBAAkB,IAAI,SAAS,EAAE;AACrD,QAAI,UAAU,OAAO,YAAY,IAAK,QAAO,OAAO;AAEpD,UAAM,WAAW,KAAK,qBAAqB,IAAI,SAAS,EAAE;AAC1D,QAAI,SAAU,QAAO;AAErB,UAAM,SAAS,YAAY;AACzB,UAAI;AACF,cAAM,QAAQ,MAAM,SAAS,YAAY;AACzC,aAAK,kBAAkB,IAAI,SAAS,IAAI;AAAA,UACtC;AAAA,UACA,WAAW,KAAK,IAAI,IAAI,KAAK;AAAA,QAC/B,CAAC;AACD,eAAO;AAAA,MACT,QAAQ;AACN,aAAK,kBAAkB,IAAI,SAAS,IAAI;AAAA,UACtC,OAAO;AAAA,UACP,WAAW,KAAK,IAAI,IAAI,KAAK;AAAA,QAC/B,CAAC;AACD,eAAO;AAAA,MACT,UAAE;AACA,aAAK,qBAAqB,OAAO,SAAS,EAAE;AAAA,MAC9C;AAAA,IACF,GAAG;AACH,SAAK,qBAAqB,IAAI,SAAS,IAAI,KAAK;AAChD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,uBAAuB,KAAqD;AACxF,UAAM,YAAY,OAAO,MAAM,KAAK,KAAK,WAAW,KAAK,CAAC;AAC1D,UAAM,aAA+B,CAAC;AACtC,eAAW,MAAM,WAAW;AAC1B,YAAM,WAAW,KAAK,WAAW,IAAI,EAAE;AACvC,UAAI,SAAU,YAAW,KAAK,QAAQ;AAAA,IACxC;AAEA,UAAM,SAAS,MAAM,QAAQ;AAAA,MAC3B,WAAW,IAAI,CAAC,aAAa,KAAK,0BAA0B,QAAQ,CAAC;AAAA,IACvE;AAEA,UAAM,YAA8B,CAAC;AACrC,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,YAAM,QAAQ,OAAO,CAAC;AACtB,UAAI,MAAM,WAAW,eAAe,MAAM,OAAO;AAC/C,kBAAU,KAAK,WAAW,CAAC,CAAC;AAAA,MAC9B;AAAA,IACF;AAEA,WAAO,UAAU,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,sBACZ,UACA,OACA,SACyB;AACzB,UAAM,SAAS,YAAY;AAC3B,WAAO,SAAS,OAAO,OAAO,OAAO;AAAA,EACvC;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,qBACZ,UACA,QACe;AACf,UAAM,SAAS,YAAY;AAC3B,WAAO,SAAS,MAAM,MAAM;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,sBACZ,UACA,UACA,UACA,UACe;AACf,UAAM,SAAS,YAAY;AAC3B,WAAO,SAAS,OAAO,UAAU,UAAU,QAAQ;AAAA,EACrD;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.6-develop.5651.1.c43359070c",
3
+ "version": "0.6.6-develop.5672.1.11e27afad2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -126,9 +126,9 @@
126
126
  "zod": "^4.4.3"
127
127
  },
128
128
  "peerDependencies": {
129
- "@open-mercato/core": "0.6.6-develop.5651.1.c43359070c",
130
- "@open-mercato/queue": "0.6.6-develop.5651.1.c43359070c",
131
- "@open-mercato/shared": "0.6.6-develop.5651.1.c43359070c"
129
+ "@open-mercato/core": "0.6.6-develop.5672.1.11e27afad2",
130
+ "@open-mercato/queue": "0.6.6-develop.5672.1.11e27afad2",
131
+ "@open-mercato/shared": "0.6.6-develop.5672.1.11e27afad2"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/jest": "^30.0.0",
@@ -355,6 +355,52 @@ describe('SearchService', () => {
355
355
  expect(availableStrategy.index).toHaveBeenCalled()
356
356
  expect(unavailableStrategy.index).not.toHaveBeenCalled()
357
357
  })
358
+
359
+ // issue #3103: a strategy index failure must surface to the caller so the
360
+ // queue worker re-throws and the job is retried instead of silently completing.
361
+ it('should reject when a strategy index fails (so the queue can retry)', async () => {
362
+ const strategy = createMockStrategy({
363
+ id: 'vector',
364
+ index: jest.fn().mockRejectedValue(new Error('sorry, too many clients already')),
365
+ })
366
+ const service = new SearchService({ strategies: [strategy] })
367
+
368
+ await expect(service.index(createMockRecord())).rejects.toThrow(
369
+ 'Search index failed for 1 strategy(ies): vector (sorry, too many clients already)'
370
+ )
371
+ })
372
+
373
+ it('should still reject when only one of several strategies fails', async () => {
374
+ const ok = createMockStrategy({ id: 'tokens', index: jest.fn().mockResolvedValue(undefined) })
375
+ const failing = createMockStrategy({
376
+ id: 'vector',
377
+ index: jest.fn().mockRejectedValue(new Error('embedding provider blip')),
378
+ })
379
+ const service = new SearchService({ strategies: [ok, failing] })
380
+
381
+ await expect(service.index(createMockRecord())).rejects.toThrow(
382
+ 'Search index failed for 1 strategy(ies): vector (embedding provider blip)'
383
+ )
384
+ // Successful strategies still commit their work.
385
+ expect(ok.index).toHaveBeenCalled()
386
+ })
387
+
388
+ it('should preserve the original strategy errors on the thrown AggregateError', async () => {
389
+ const cause = new Error('sorry, too many clients already')
390
+ const strategy = createMockStrategy({ id: 'vector', index: jest.fn().mockRejectedValue(cause) })
391
+ const service = new SearchService({ strategies: [strategy] })
392
+
393
+ await expect(service.index(createMockRecord())).rejects.toMatchObject({
394
+ errors: [cause],
395
+ })
396
+ })
397
+
398
+ it('should resolve when all strategies succeed', async () => {
399
+ const strategy = createMockStrategy({ id: 'tokens', index: jest.fn().mockResolvedValue(undefined) })
400
+ const service = new SearchService({ strategies: [strategy] })
401
+
402
+ await expect(service.index(createMockRecord())).resolves.toBeUndefined()
403
+ })
358
404
  })
359
405
 
360
406
  describe('bulkIndex', () => {
@@ -439,6 +485,19 @@ describe('SearchService', () => {
439
485
  expect(strategy1.delete).toHaveBeenCalledWith('test:entity', 'rec-123', 'tenant-123')
440
486
  expect(strategy2.delete).toHaveBeenCalledWith('test:entity', 'rec-123', 'tenant-123')
441
487
  })
488
+
489
+ // issue #3103: deletes must also surface failures so removals are retried.
490
+ it('should reject when a strategy delete fails (so the queue can retry)', async () => {
491
+ const strategy = createMockStrategy({
492
+ id: 'fulltext',
493
+ delete: jest.fn().mockRejectedValue(new Error('connection reset')),
494
+ })
495
+ const service = new SearchService({ strategies: [strategy] })
496
+
497
+ await expect(service.delete('test:entity', 'rec-123', 'tenant-123')).rejects.toThrow(
498
+ 'Search delete failed for 1 strategy(ies): fulltext (connection reset)'
499
+ )
500
+ })
442
501
  })
443
502
 
444
503
  describe('purge', () => {
@@ -460,6 +519,19 @@ describe('SearchService', () => {
460
519
  // organizationId is forwarded as undefined for a tenant-wide purge (issue #2935)
461
520
  expect(strategyWithPurge.purge).toHaveBeenCalledWith('test:entity', 'tenant-123', undefined)
462
521
  })
522
+
523
+ // issue #3103: purge failures must surface so the reindex job is retried.
524
+ it('should reject when a strategy purge fails (so the queue can retry)', async () => {
525
+ const strategy = createMockStrategy({
526
+ id: 'tokens',
527
+ purge: jest.fn().mockRejectedValue(new Error('sorry, too many clients already')),
528
+ })
529
+ const service = new SearchService({ strategies: [strategy] })
530
+
531
+ await expect(service.purge('test:entity', 'tenant-123')).rejects.toThrow(
532
+ 'Search purge failed for 1 strategy(ies): tokens (sorry, too many clients already)'
533
+ )
534
+ })
463
535
  })
464
536
 
465
537
  describe('availability checks (issue #1404)', () => {
package/src/service.ts CHANGED
@@ -202,19 +202,10 @@ export class SearchService {
202
202
  strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),
203
203
  )
204
204
 
205
- // Log any failures
206
- for (let i = 0; i < results.length; i++) {
207
- const result = results[i]
208
- if (result.status === 'rejected') {
209
- const strategy = strategies[i]
210
- searchError('SearchService', 'Strategy index failed', {
211
- strategyId: strategy?.id,
212
- entityId: record.entityId,
213
- recordId: record.recordId,
214
- error: result.reason instanceof Error ? result.reason.message : result.reason,
215
- })
216
- }
217
- }
205
+ this.throwOnStrategyFailures('index', strategies, results, {
206
+ entityId: record.entityId,
207
+ recordId: record.recordId,
208
+ })
218
209
  }
219
210
 
220
211
  /**
@@ -231,19 +222,7 @@ export class SearchService {
231
222
  strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),
232
223
  )
233
224
 
234
- // Log any failures
235
- for (let i = 0; i < results.length; i++) {
236
- const result = results[i]
237
- if (result.status === 'rejected') {
238
- const strategy = strategies[i]
239
- searchError('SearchService', 'Strategy delete failed', {
240
- strategyId: strategy?.id,
241
- entityId,
242
- recordId,
243
- error: result.reason instanceof Error ? result.reason.message : result.reason,
244
- })
245
- }
246
- }
225
+ this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })
247
226
  }
248
227
 
249
228
  /**
@@ -312,18 +291,54 @@ export class SearchService {
312
291
  }),
313
292
  )
314
293
 
315
- // Log any failures
294
+ this.throwOnStrategyFailures('purge', strategies, results, { entityId })
295
+ }
296
+
297
+ /**
298
+ * Inspect the settled results of a per-strategy write operation, log every
299
+ * rejection, and re-throw an aggregated error when any strategy failed.
300
+ *
301
+ * Write operations (index/delete/purge) must surface failures to the caller
302
+ * so the queue worker re-throws and the job is retried. Swallowing rejections
303
+ * here causes silent, permanent index gaps on transient failures such as
304
+ * Postgres connection-pool exhaustion (issue #3103). Successful strategies
305
+ * still commit their work; only the aggregated failure propagates.
306
+ */
307
+ private throwOnStrategyFailures(
308
+ operation: 'index' | 'delete' | 'purge',
309
+ strategies: SearchStrategy[],
310
+ results: PromiseSettledResult<unknown>[],
311
+ context: { entityId: string; recordId?: string },
312
+ ): void {
313
+ const failures: Array<{ strategyId: string; reason: unknown }> = []
314
+
316
315
  for (let i = 0; i < results.length; i++) {
317
316
  const result = results[i]
318
317
  if (result.status === 'rejected') {
319
318
  const strategy = strategies[i]
320
- searchError('SearchService', 'Strategy purge failed', {
319
+ failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })
320
+ searchError('SearchService', `Strategy ${operation} failed`, {
321
321
  strategyId: strategy?.id,
322
- entityId,
322
+ entityId: context.entityId,
323
+ recordId: context.recordId,
323
324
  error: result.reason instanceof Error ? result.reason.message : result.reason,
324
325
  })
325
326
  }
326
327
  }
328
+
329
+ if (failures.length === 0) return
330
+
331
+ const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures
332
+ .map((failure) => {
333
+ const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)
334
+ return `${failure.strategyId} (${message})`
335
+ })
336
+ .join(', ')}`
337
+
338
+ throw new AggregateError(
339
+ failures.map((failure) => failure.reason),
340
+ summary,
341
+ )
327
342
  }
328
343
 
329
344
  /**