@open-mercato/search 0.6.6-develop.6410.1.2ad881013e → 0.6.6-develop.6412.1.e99f3c32d9

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/di.js CHANGED
@@ -6,6 +6,10 @@ import { FullTextSearchStrategy } from "./strategies/fulltext.strategy.js";
6
6
  import { createFulltextDriver } from "./fulltext/drivers/index.js";
7
7
  import { SearchIndexer } from "./indexer/search-indexer.js";
8
8
  import { createPresenterEnricher } from "./lib/presenter-enricher.js";
9
+ const FULLTEXT_DRIVER_KEY = "__omSearchFulltextDriver__";
10
+ function getSearchModuleGlobals() {
11
+ return globalThis;
12
+ }
9
13
  function shouldExcludeEncryptedFields() {
10
14
  const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? "").toLowerCase();
11
15
  return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
@@ -62,22 +66,32 @@ function registerSearchModule(container, options) {
62
66
  }
63
67
  }
64
68
  if (!options?.skipFulltext) {
65
- let encryptionMapResolver;
66
- if (shouldExcludeEncryptedFields()) {
67
- try {
68
- const em = container.resolve("em");
69
- const db = em.getKysely();
70
- encryptionMapResolver = createEncryptionMapResolver(db);
71
- } catch {
69
+ const excludeEncrypted = shouldExcludeEncryptedFields();
70
+ const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== "1";
71
+ const g = getSearchModuleGlobals();
72
+ const canMemoize = singletonCacheEnabled && !excludeEncrypted;
73
+ let fulltextDriver = canMemoize ? g[FULLTEXT_DRIVER_KEY] ?? null : null;
74
+ if (!fulltextDriver) {
75
+ let encryptionMapResolver;
76
+ if (excludeEncrypted) {
77
+ try {
78
+ const em = container.resolve("em");
79
+ const db = em.getKysely();
80
+ encryptionMapResolver = createEncryptionMapResolver(db);
81
+ } catch {
82
+ }
83
+ }
84
+ fulltextDriver = createFulltextDriver({
85
+ fieldPolicyResolver: (entityId) => {
86
+ const config = entityConfigMap.get(entityId);
87
+ return config?.fieldPolicy;
88
+ },
89
+ encryptionMapResolver
90
+ });
91
+ if (canMemoize && fulltextDriver) {
92
+ g[FULLTEXT_DRIVER_KEY] = fulltextDriver;
72
93
  }
73
94
  }
74
- const fulltextDriver = createFulltextDriver({
75
- fieldPolicyResolver: (entityId) => {
76
- const config = entityConfigMap.get(entityId);
77
- return config?.fieldPolicy;
78
- },
79
- encryptionMapResolver
80
- });
81
95
  if (fulltextDriver) {
82
96
  strategies.push(new FullTextSearchStrategy(fulltextDriver));
83
97
  }
package/dist/di.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/di.ts"],
4
- "sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Create an encryption map resolver that queries the database.\n * Falls back to empty array if query fails.\n */\nfunction createEncryptionMapResolver(\n db: Kysely<any>,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n // Cache encryption maps per entity to avoid repeated queries\n const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()\n const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const cached = cache.get(entityId)\n if (cached && cached.expiresAt > Date.now()) {\n return cached.entries\n }\n\n try {\n const row = await db\n .selectFrom('encryption_maps' as any)\n .select(['fields_json' as any])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .executeTakeFirst() as { fields_json?: unknown } | undefined\n\n const fieldsJson = row?.fields_json\n const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)\n ? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({\n field: f.field,\n hashField: f.hashField ?? null,\n }))\n : []\n\n cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })\n return entries\n } catch {\n // Query failed, return empty array (don't exclude any fields)\n return []\n }\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n // Build encryption map resolver if SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (shouldExcludeEncryptedFields()) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n // Kysely not available, encrypted field filtering disabled\n }\n }\n\n const fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport type { FullTextSearchDriver } from './fulltext/types'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\n\nconst FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'\n\ntype SearchModuleGlobals = {\n [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver\n}\n\nfunction getSearchModuleGlobals(): SearchModuleGlobals {\n return globalThis as unknown as SearchModuleGlobals\n}\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Create an encryption map resolver that queries the database.\n * Falls back to empty array if query fails.\n */\nfunction createEncryptionMapResolver(\n db: Kysely<any>,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n // Cache encryption maps per entity to avoid repeated queries\n const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()\n const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const cached = cache.get(entityId)\n if (cached && cached.expiresAt > Date.now()) {\n return cached.entries\n }\n\n try {\n const row = await db\n .selectFrom('encryption_maps' as any)\n .select(['fields_json' as any])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .executeTakeFirst() as { fields_json?: unknown } | undefined\n\n const fieldsJson = row?.fields_json\n const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)\n ? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({\n field: f.field,\n hashField: f.hashField ?? null,\n }))\n : []\n\n cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })\n return entries\n } catch {\n // Query failed, return empty array (don't exclude any fields)\n return []\n }\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n const excludeEncrypted = shouldExcludeEncryptedFields()\n const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n const g = getSearchModuleGlobals()\n\n // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is\n // OFF \u2014 in that case the driver holds no request-scoped Kysely reference. When the flag\n // is ON the encryptionMapResolver captures a per-request db handle and must stay\n // per-request to avoid cross-request data leaks.\n const canMemoize = singletonCacheEnabled && !excludeEncrypted\n\n let fulltextDriver: FullTextSearchDriver | null = canMemoize\n ? (g[FULLTEXT_DRIVER_KEY] ?? null)\n : null\n\n if (!fulltextDriver) {\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (excludeEncrypted) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n // Kysely not available, encrypted field filtering disabled\n }\n }\n\n fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (canMemoize && fulltextDriver) {\n g[FULLTEXT_DRIVER_KEY] = fulltextDriver\n }\n }\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,18 +1,50 @@
1
1
  import { asValue } from "awilix";
2
+ import { Pool } from "pg";
2
3
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
3
4
  import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from "../../vector/index.js";
4
5
  import { createVectorIndexingQueue } from "../../queue/vector-indexing.js";
5
6
  import { createFulltextIndexingQueue } from "../../queue/fulltext-indexing.js";
6
7
  import { createEmbeddingProviderProbe } from "./lib/provider-probe.js";
7
- function register(container) {
8
+ const EMBEDDING_SERVICE_KEY = "__omSearchEmbeddingService__";
9
+ const VECTOR_DRIVERS_KEY = "__omSearchVectorDrivers__";
10
+ const VECTOR_INDEX_QUEUE_KEY = "__omSearchVectorIndexQueue__";
11
+ const FULLTEXT_INDEX_QUEUE_KEY = "__omSearchFulltextIndexQueue__";
12
+ const PG_POOL_KEY = "__omSearchVectorPgPool__";
13
+ const SHUTDOWN_KEY = "__omSearchSingletonsShutdown__";
14
+ function getSearchGlobals() {
15
+ return globalThis;
16
+ }
17
+ function isSingletonCacheEnabled() {
18
+ return process.env.SEARCH_DISABLE_SINGLETON_CACHE !== "1";
19
+ }
20
+ function buildQueueOptions() {
21
+ const queueStrategy = process.env.QUEUE_STRATEGY || "local";
22
+ const queueConnection = queueStrategy === "async" ? { connection: { url: getRedisUrlOrThrow("QUEUE") } } : void 0;
23
+ return { queueStrategy, queueConnection };
24
+ }
25
+ function getOrCreateSingletons() {
26
+ const g = getSearchGlobals();
27
+ if (g[EMBEDDING_SERVICE_KEY] && g[VECTOR_DRIVERS_KEY] && g[VECTOR_INDEX_QUEUE_KEY] && g[FULLTEXT_INDEX_QUEUE_KEY]) {
28
+ return {
29
+ embeddingService: g[EMBEDDING_SERVICE_KEY],
30
+ drivers: g[VECTOR_DRIVERS_KEY],
31
+ vectorIndexQueue: g[VECTOR_INDEX_QUEUE_KEY],
32
+ fulltextIndexQueue: g[FULLTEXT_INDEX_QUEUE_KEY]
33
+ };
34
+ }
8
35
  const embeddingService = new EmbeddingService();
36
+ let pgPool;
37
+ const dbUrl = process.env.DATABASE_URL;
38
+ if (dbUrl) {
39
+ pgPool = new Pool({ connectionString: dbUrl });
40
+ g[PG_POOL_KEY] = pgPool;
41
+ }
9
42
  const drivers = [
10
- createPgVectorDriver(),
43
+ createPgVectorDriver(pgPool ? { pool: pgPool } : {}),
11
44
  createChromaDbDriver(),
12
45
  createQdrantDriver()
13
46
  ];
14
- const queueStrategy = process.env.QUEUE_STRATEGY || "local";
15
- const queueConnection = queueStrategy === "async" ? { connection: { url: getRedisUrlOrThrow("QUEUE") } } : void 0;
47
+ const { queueStrategy, queueConnection } = buildQueueOptions();
16
48
  const vectorIndexQueue = createVectorIndexingQueue(
17
49
  queueStrategy,
18
50
  queueConnection
@@ -21,6 +53,41 @@ function register(container) {
21
53
  queueStrategy,
22
54
  queueConnection
23
55
  );
56
+ g[EMBEDDING_SERVICE_KEY] = embeddingService;
57
+ g[VECTOR_DRIVERS_KEY] = drivers;
58
+ g[VECTOR_INDEX_QUEUE_KEY] = vectorIndexQueue;
59
+ g[FULLTEXT_INDEX_QUEUE_KEY] = fulltextIndexQueue;
60
+ if (!g[SHUTDOWN_KEY]) {
61
+ const shutdown = () => {
62
+ g[PG_POOL_KEY]?.end().catch(() => {
63
+ });
64
+ g[VECTOR_INDEX_QUEUE_KEY]?.close().catch(() => {
65
+ });
66
+ g[FULLTEXT_INDEX_QUEUE_KEY]?.close().catch(() => {
67
+ });
68
+ };
69
+ process.once("SIGTERM", shutdown);
70
+ process.once("SIGINT", shutdown);
71
+ g[SHUTDOWN_KEY] = true;
72
+ }
73
+ return { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue };
74
+ }
75
+ function register(container) {
76
+ if (!isSingletonCacheEnabled()) {
77
+ const { queueStrategy, queueConnection } = buildQueueOptions();
78
+ container.register({
79
+ vectorEmbeddingService: asValue(new EmbeddingService()),
80
+ vectorDrivers: asValue([
81
+ createPgVectorDriver(),
82
+ createChromaDbDriver(),
83
+ createQdrantDriver()
84
+ ]),
85
+ vectorIndexQueue: asValue(createVectorIndexingQueue(queueStrategy, queueConnection)),
86
+ fulltextIndexQueue: asValue(createFulltextIndexingQueue(queueStrategy, queueConnection))
87
+ });
88
+ return;
89
+ }
90
+ const { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue } = getOrCreateSingletons();
24
91
  container.register({
25
92
  vectorEmbeddingService: asValue(embeddingService),
26
93
  vectorDrivers: asValue(drivers),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/search/di.ts"],
4
- "sourcesContent": ["import { asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'\nimport { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'\nimport { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'\nimport { createEmbeddingProviderProbe } from './lib/provider-probe'\nimport type { Queue } from '@open-mercato/queue'\n\n/**\n * Register search module dependencies.\n *\n * This registers:\n * - vectorEmbeddingService: EmbeddingService for creating embeddings\n * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)\n * - vectorIndexQueue: Queue for vector indexing jobs\n * - fulltextIndexQueue: Queue for fulltext indexing jobs\n *\n * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,\n * which is registered in the main search module DI (packages/search/src/di.ts).\n */\nexport function register(container: AppContainer) {\n const embeddingService = new EmbeddingService()\n const drivers = [\n createPgVectorDriver(),\n createChromaDbDriver(),\n createQdrantDriver(),\n ]\n\n // Create queues based on environment strategy\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const queueConnection = queueStrategy === 'async'\n ? { connection: { url: getRedisUrlOrThrow('QUEUE') } }\n : undefined\n\n const vectorIndexQueue: Queue<VectorIndexJobPayload> = createVectorIndexingQueue(\n queueStrategy,\n queueConnection,\n )\n\n const fulltextIndexQueue: Queue<FulltextIndexJobPayload> = createFulltextIndexingQueue(\n queueStrategy,\n queueConnection,\n )\n\n container.register({\n vectorEmbeddingService: asValue(embeddingService),\n vectorDrivers: asValue(drivers),\n vectorIndexQueue: asValue(vectorIndexQueue),\n fulltextIndexQueue: asValue(fulltextIndexQueue),\n embeddingProviderProbe: asValue(createEmbeddingProviderProbe(container)),\n })\n}\n"],
5
- "mappings": "AAAA,SAAS,eAAe;AAExB,SAAS,0BAA0B;AACnC,SAAS,kBAAkB,sBAAsB,sBAAsB,0BAA0B;AACjG,SAAS,iCAA6D;AACtE,SAAS,mCAAiE;AAC1E,SAAS,oCAAoC;AAetC,SAAS,SAAS,WAAyB;AAChD,QAAM,mBAAmB,IAAI,iBAAiB;AAC9C,QAAM,UAAU;AAAA,IACd,qBAAqB;AAAA,IACrB,qBAAqB;AAAA,IACrB,mBAAmB;AAAA,EACrB;AAGA,QAAM,gBAAiB,QAAQ,IAAI,kBAAkB;AACrD,QAAM,kBAAkB,kBAAkB,UACtC,EAAE,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE,EAAE,IACnD;AAEJ,QAAM,mBAAiD;AAAA,IACrD;AAAA,IACA;AAAA,EACF;AAEA,QAAM,qBAAqD;AAAA,IACzD;AAAA,IACA;AAAA,EACF;AAEA,YAAU,SAAS;AAAA,IACjB,wBAAwB,QAAQ,gBAAgB;AAAA,IAChD,eAAe,QAAQ,OAAO;AAAA,IAC9B,kBAAkB,QAAQ,gBAAgB;AAAA,IAC1C,oBAAoB,QAAQ,kBAAkB;AAAA,IAC9C,wBAAwB,QAAQ,6BAA6B,SAAS,CAAC;AAAA,EACzE,CAAC;AACH;",
4
+ "sourcesContent": ["import { asValue } from 'awilix'\nimport { Pool } from 'pg'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'\nimport type { PgPool } from '../../vector/drivers/pgvector'\nimport { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'\nimport { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'\nimport { createEmbeddingProviderProbe } from './lib/provider-probe'\nimport type { Queue } from '@open-mercato/queue'\n\nconst EMBEDDING_SERVICE_KEY = '__omSearchEmbeddingService__'\nconst VECTOR_DRIVERS_KEY = '__omSearchVectorDrivers__'\nconst VECTOR_INDEX_QUEUE_KEY = '__omSearchVectorIndexQueue__'\nconst FULLTEXT_INDEX_QUEUE_KEY = '__omSearchFulltextIndexQueue__'\nconst PG_POOL_KEY = '__omSearchVectorPgPool__'\nconst SHUTDOWN_KEY = '__omSearchSingletonsShutdown__'\n\ntype SearchSingletonCache = {\n [EMBEDDING_SERVICE_KEY]?: EmbeddingService\n [VECTOR_DRIVERS_KEY]?: ReturnType<typeof createPgVectorDriver>[]\n [VECTOR_INDEX_QUEUE_KEY]?: Queue<VectorIndexJobPayload>\n [FULLTEXT_INDEX_QUEUE_KEY]?: Queue<FulltextIndexJobPayload>\n [PG_POOL_KEY]?: PgPool\n [SHUTDOWN_KEY]?: boolean\n}\n\nfunction getSearchGlobals(): SearchSingletonCache {\n // L1: these globals are NOT cleared on HMR \u2014 module re-evaluation hits the\n // cache-hit fast-path so no new connections are opened. Restart the dev server\n // (or set SEARCH_DISABLE_SINGLETON_CACHE=1) when a clean slate is needed.\n return globalThis as unknown as SearchSingletonCache\n}\n\nfunction isSingletonCacheEnabled(): boolean {\n return process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n}\n\nfunction buildQueueOptions(): {\n queueStrategy: 'local' | 'async'\n queueConnection: { connection: { url: string } } | undefined\n} {\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const queueConnection = queueStrategy === 'async'\n ? { connection: { url: getRedisUrlOrThrow('QUEUE') } }\n : undefined\n return { queueStrategy, queueConnection }\n}\n\nfunction getOrCreateSingletons(): {\n embeddingService: EmbeddingService\n drivers: ReturnType<typeof createPgVectorDriver>[]\n vectorIndexQueue: Queue<VectorIndexJobPayload>\n fulltextIndexQueue: Queue<FulltextIndexJobPayload>\n} {\n const g = getSearchGlobals()\n\n if (\n g[EMBEDDING_SERVICE_KEY] &&\n g[VECTOR_DRIVERS_KEY] &&\n g[VECTOR_INDEX_QUEUE_KEY] &&\n g[FULLTEXT_INDEX_QUEUE_KEY]\n ) {\n return {\n embeddingService: g[EMBEDDING_SERVICE_KEY],\n drivers: g[VECTOR_DRIVERS_KEY],\n vectorIndexQueue: g[VECTOR_INDEX_QUEUE_KEY],\n fulltextIndexQueue: g[FULLTEXT_INDEX_QUEUE_KEY],\n }\n }\n\n const embeddingService = new EmbeddingService()\n\n // Create the pgvector pool separately so we can close it in the shutdown hook\n let pgPool: PgPool | undefined\n const dbUrl = process.env.DATABASE_URL\n if (dbUrl) {\n pgPool = new Pool({ connectionString: dbUrl }) as unknown as PgPool\n g[PG_POOL_KEY] = pgPool\n }\n\n const drivers = [\n createPgVectorDriver(pgPool ? { pool: pgPool } : {}),\n createChromaDbDriver(),\n createQdrantDriver(),\n ]\n\n const { queueStrategy, queueConnection } = buildQueueOptions()\n\n const vectorIndexQueue: Queue<VectorIndexJobPayload> = createVectorIndexingQueue(\n queueStrategy,\n queueConnection,\n )\n\n const fulltextIndexQueue: Queue<FulltextIndexJobPayload> = createFulltextIndexingQueue(\n queueStrategy,\n queueConnection,\n )\n\n g[EMBEDDING_SERVICE_KEY] = embeddingService\n g[VECTOR_DRIVERS_KEY] = drivers\n g[VECTOR_INDEX_QUEUE_KEY] = vectorIndexQueue\n g[FULLTEXT_INDEX_QUEUE_KEY] = fulltextIndexQueue\n\n if (!g[SHUTDOWN_KEY]) {\n const shutdown = () => {\n // L2: chromadb/qdrant drivers use stateless HTTP clients \u2014 no persistent\n // connection to close. Only the pg.Pool and BullMQ queues need teardown.\n g[PG_POOL_KEY]?.end().catch(() => {})\n g[VECTOR_INDEX_QUEUE_KEY]?.close().catch(() => {})\n g[FULLTEXT_INDEX_QUEUE_KEY]?.close().catch(() => {})\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n g[SHUTDOWN_KEY] = true\n }\n\n return { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue }\n}\n\n/**\n * Register search module dependencies.\n *\n * This registers:\n * - vectorEmbeddingService: EmbeddingService for creating embeddings\n * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)\n * - vectorIndexQueue: Queue for vector indexing jobs\n * - fulltextIndexQueue: Queue for fulltext indexing jobs\n *\n * These four are process-scoped singletons (globalThis) to avoid per-request pg.Pool\n * creation, DDL re-runs, and BullMQ/ioredis connection leaks. Disable with\n * SEARCH_DISABLE_SINGLETON_CACHE=1.\n *\n * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,\n * which is registered in the main search module DI (packages/search/src/di.ts).\n */\nexport function register(container: AppContainer) {\n if (!isSingletonCacheEnabled()) {\n const { queueStrategy, queueConnection } = buildQueueOptions()\n\n container.register({\n vectorEmbeddingService: asValue(new EmbeddingService()),\n vectorDrivers: asValue([\n createPgVectorDriver(),\n createChromaDbDriver(),\n createQdrantDriver(),\n ]),\n vectorIndexQueue: asValue(createVectorIndexingQueue(queueStrategy, queueConnection)),\n fulltextIndexQueue: asValue(createFulltextIndexingQueue(queueStrategy, queueConnection)),\n })\n return\n }\n\n const { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue } = getOrCreateSingletons()\n\n container.register({\n vectorEmbeddingService: asValue(embeddingService),\n vectorDrivers: asValue(drivers),\n vectorIndexQueue: asValue(vectorIndexQueue),\n fulltextIndexQueue: asValue(fulltextIndexQueue),\n embeddingProviderProbe: asValue(createEmbeddingProviderProbe(container)),\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,eAAe;AACxB,SAAS,YAAY;AAErB,SAAS,0BAA0B;AACnC,SAAS,kBAAkB,sBAAsB,sBAAsB,0BAA0B;AAEjG,SAAS,iCAA6D;AACtE,SAAS,mCAAiE;AAC1E,SAAS,oCAAoC;AAG7C,MAAM,wBAAwB;AAC9B,MAAM,qBAAqB;AAC3B,MAAM,yBAAyB;AAC/B,MAAM,2BAA2B;AACjC,MAAM,cAAc;AACpB,MAAM,eAAe;AAWrB,SAAS,mBAAyC;AAIhD,SAAO;AACT;AAEA,SAAS,0BAAmC;AAC1C,SAAO,QAAQ,IAAI,mCAAmC;AACxD;AAEA,SAAS,oBAGP;AACA,QAAM,gBAAiB,QAAQ,IAAI,kBAAkB;AACrD,QAAM,kBAAkB,kBAAkB,UACtC,EAAE,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE,EAAE,IACnD;AACJ,SAAO,EAAE,eAAe,gBAAgB;AAC1C;AAEA,SAAS,wBAKP;AACA,QAAM,IAAI,iBAAiB;AAE3B,MACE,EAAE,qBAAqB,KACvB,EAAE,kBAAkB,KACpB,EAAE,sBAAsB,KACxB,EAAE,wBAAwB,GAC1B;AACA,WAAO;AAAA,MACL,kBAAkB,EAAE,qBAAqB;AAAA,MACzC,SAAS,EAAE,kBAAkB;AAAA,MAC7B,kBAAkB,EAAE,sBAAsB;AAAA,MAC1C,oBAAoB,EAAE,wBAAwB;AAAA,IAChD;AAAA,EACF;AAEA,QAAM,mBAAmB,IAAI,iBAAiB;AAG9C,MAAI;AACJ,QAAM,QAAQ,QAAQ,IAAI;AAC1B,MAAI,OAAO;AACT,aAAS,IAAI,KAAK,EAAE,kBAAkB,MAAM,CAAC;AAC7C,MAAE,WAAW,IAAI;AAAA,EACnB;AAEA,QAAM,UAAU;AAAA,IACd,qBAAqB,SAAS,EAAE,MAAM,OAAO,IAAI,CAAC,CAAC;AAAA,IACnD,qBAAqB;AAAA,IACrB,mBAAmB;AAAA,EACrB;AAEA,QAAM,EAAE,eAAe,gBAAgB,IAAI,kBAAkB;AAE7D,QAAM,mBAAiD;AAAA,IACrD;AAAA,IACA;AAAA,EACF;AAEA,QAAM,qBAAqD;AAAA,IACzD;AAAA,IACA;AAAA,EACF;AAEA,IAAE,qBAAqB,IAAI;AAC3B,IAAE,kBAAkB,IAAI;AACxB,IAAE,sBAAsB,IAAI;AAC5B,IAAE,wBAAwB,IAAI;AAE9B,MAAI,CAAC,EAAE,YAAY,GAAG;AACpB,UAAM,WAAW,MAAM;AAGrB,QAAE,WAAW,GAAG,IAAI,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACpC,QAAE,sBAAsB,GAAG,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACjD,QAAE,wBAAwB,GAAG,MAAM,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACrD;AACA,YAAQ,KAAK,WAAW,QAAQ;AAChC,YAAQ,KAAK,UAAU,QAAQ;AAC/B,MAAE,YAAY,IAAI;AAAA,EACpB;AAEA,SAAO,EAAE,kBAAkB,SAAS,kBAAkB,mBAAmB;AAC3E;AAkBO,SAAS,SAAS,WAAyB;AAChD,MAAI,CAAC,wBAAwB,GAAG;AAC9B,UAAM,EAAE,eAAe,gBAAgB,IAAI,kBAAkB;AAE7D,cAAU,SAAS;AAAA,MACjB,wBAAwB,QAAQ,IAAI,iBAAiB,CAAC;AAAA,MACtD,eAAe,QAAQ;AAAA,QACrB,qBAAqB;AAAA,QACrB,qBAAqB;AAAA,QACrB,mBAAmB;AAAA,MACrB,CAAC;AAAA,MACD,kBAAkB,QAAQ,0BAA0B,eAAe,eAAe,CAAC;AAAA,MACnF,oBAAoB,QAAQ,4BAA4B,eAAe,eAAe,CAAC;AAAA,IACzF,CAAC;AACD;AAAA,EACF;AAEA,QAAM,EAAE,kBAAkB,SAAS,kBAAkB,mBAAmB,IAAI,sBAAsB;AAElG,YAAU,SAAS;AAAA,IACjB,wBAAwB,QAAQ,gBAAgB;AAAA,IAChD,eAAe,QAAQ,OAAO;AAAA,IAC9B,kBAAkB,QAAQ,gBAAgB;AAAA,IAC1C,oBAAoB,QAAQ,kBAAkB;AAAA,IAC9C,wBAAwB,QAAQ,6BAA6B,SAAS,CAAC;AAAA,EACzE,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/vector/drivers/pgvector/index.ts"],
4
- "sourcesContent": ["import { Pool } from 'pg'\nimport { searchDebugWarn } from '../../../lib/debug'\n\ntype PgPoolQueryResult<T> = { rows: T[]; rowCount?: number }\ntype PgPoolClient = {\n query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>\n release(): void\n}\ntype PgPool = {\n connect(): Promise<PgPoolClient>\n query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>\n end(): Promise<void>\n}\nimport type {\n VectorDriver,\n VectorDriverDocument,\n VectorDriverQuery,\n VectorDriverQueryResult,\n VectorDriverListParams,\n VectorDriverCountParams,\n VectorIndexEntry,\n VectorDriverRemoveOrphansParams,\n VectorResultPresenter,\n VectorLinkDescriptor,\n} from '../../types'\n\ntype PgVectorDriverOptions = {\n pool?: PgPool\n connectionString?: string\n tableName?: string\n migrationsTable?: string\n dimension?: number\n distanceMetric?: 'cosine' | 'euclidean' | 'inner'\n}\n\nconst DEFAULT_TABLE = 'vector_search'\nconst DEFAULT_MIGRATIONS_TABLE = 'vector_search_migrations'\nconst DEFAULT_DIMENSION = 1536\nconst DRIVER_ID = 'pgvector' as const\n\nfunction assertIdentifier(name: string, defaultName: string): string {\n const candidate = name ?? defaultName\n if (!candidate || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(candidate)) return defaultName\n return candidate\n}\n\nfunction quoteIdent(name: string): string {\n return `\"${name}\"`\n}\n\nfunction toVectorLiteral(values: number[]): string {\n const formatted = values.map((n) => {\n if (!Number.isFinite(n)) return '0'\n const rounded = Math.fround(n)\n return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`\n })\n return `[${formatted.join(',')}]`\n}\n\nfunction parseJsonColumn<T>(value: unknown): T | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'string') {\n try {\n return JSON.parse(value) as T\n } catch {\n // When `jsonb` stores a JSON string, node-postgres parses it into a plain JS string.\n // In that case, there is nothing to JSON.parse \u2014 return the raw string value.\n return value as unknown as T\n }\n }\n if (typeof value === 'object') {\n return value as T\n }\n return null\n}\n\nasync function withClient<T>(pool: PgPool, fn: (client: PgPoolClient) => Promise<T>): Promise<T> {\n const client = await pool.connect()\n try {\n return await fn(client)\n } finally {\n client.release()\n }\n}\n\nexport function createPgVectorDriver(opts: PgVectorDriverOptions = {}): VectorDriver {\n const tableName = assertIdentifier(opts.tableName ?? DEFAULT_TABLE, DEFAULT_TABLE)\n const migrationsTable = assertIdentifier(opts.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE, DEFAULT_MIGRATIONS_TABLE)\n let dimension = opts.dimension ?? DEFAULT_DIMENSION\n const distanceMetric = opts.distanceMetric ?? 'cosine'\n const tableIdent = quoteIdent(tableName)\n const migrationsIdent = quoteIdent(migrationsTable)\n\n const pool: PgPool =\n opts.pool ??\n (() => {\n const conn = opts.connectionString ?? process.env.DATABASE_URL\n if (!conn) {\n throw new Error('[vector.pgvector] DATABASE_URL is not configured')\n }\n return new Pool({ connectionString: conn }) as unknown as PgPool\n })()\n\n let ready: Promise<void> | null = null\n\n const ensureReady = async () => {\n if (!ready) {\n ready = withClient(pool, async (client) => {\n const ensureExtension = async (extension: 'pgcrypto' | 'vector') => {\n try {\n await client.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`)\n } catch (error) {\n const pgError = error as { code?: string; message?: string }\n if (pgError?.code === '42501') {\n const details = pgError.message ? ` (${pgError.message})` : ''\n searchDebugWarn('vector.pgvector', `skipping ${extension} extension creation; requires superuser${details}`)\n return\n }\n throw error\n }\n }\n\n await ensureExtension('pgcrypto')\n await ensureExtension('vector')\n\n await client.query(\n `CREATE TABLE IF NOT EXISTS ${migrationsIdent} (\n id text primary key,\n applied_at timestamptz not null default now()\n )`,\n )\n\n await client.query(\n `CREATE TABLE IF NOT EXISTS ${tableIdent} (\n id uuid primary key default gen_random_uuid(),\n driver_id text not null,\n entity_id text not null,\n record_id text not null,\n tenant_id uuid not null,\n organization_id uuid null,\n checksum text not null,\n embedding vector(${dimension}) not null,\n url text null,\n presenter jsonb null,\n links jsonb null,\n payload jsonb null,\n result_title text null,\n result_subtitle text null,\n result_icon text null,\n result_badge text null,\n result_snapshot text null,\n primary_link_href text null,\n primary_link_label text null,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n )`,\n )\n\n await client.query(\n `CREATE UNIQUE INDEX IF NOT EXISTS ${tableName}_uniq ON ${tableIdent} (driver_id, entity_id, record_id, tenant_id)`,\n )\n await client.query(\n `CREATE INDEX IF NOT EXISTS ${tableName}_lookup ON ${tableIdent} (tenant_id, organization_id, entity_id)`,\n )\n // ivfflat index only supports up to 2000 dimensions\n // For higher dimensions, skip the index (uses sequential scan, slower but works)\n // Also check actual table dimension in case driver was initialized with different value\n let actualDimension = dimension\n try {\n const dimResult = await client.query<{ atttypmod: number }>(\n `SELECT a.atttypmod\n FROM pg_attribute a\n JOIN pg_class c ON a.attrelid = c.oid\n WHERE c.relname = $1\n AND a.attname = 'embedding'\n AND a.atttypmod > 0`,\n [tableName]\n )\n if (dimResult.rows.length > 0 && dimResult.rows[0].atttypmod > 0) {\n actualDimension = dimResult.rows[0].atttypmod\n }\n } catch {\n // Ignore errors reading dimension, use configured value\n }\n\n if (actualDimension <= 2000) {\n try {\n await client.query(\n `CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx ON ${tableIdent}\n USING ivfflat (embedding vector_${distanceMetric}_ops) WITH (lists = 100)`,\n )\n } catch (indexErr: unknown) {\n // Handle case where dimension exceeds ivfflat limit\n const errorMessage = indexErr instanceof Error ? indexErr.message : String(indexErr)\n if (errorMessage.includes('2000 dimensions')) {\n searchDebugWarn('pgvector', 'Skipping ivfflat index - dimension exceeds 2000 limit. Searches will use sequential scan.')\n } else {\n throw indexErr\n }\n }\n } else {\n searchDebugWarn('pgvector', `Skipping ivfflat index - dimension ${actualDimension} exceeds 2000 limit. Searches will use sequential scan.`)\n }\n\n const columnAlters = [\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_title text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_subtitle text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_icon text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_badge text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_snapshot text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_href text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_label text`,\n ]\n for (const statement of columnAlters) {\n await client.query(statement)\n }\n\n await client.query(\n `INSERT INTO ${migrationsIdent} (id, applied_at) VALUES ($1, now()) ON CONFLICT (id) DO NOTHING`,\n ['0001_init'],\n )\n }).catch((err) => {\n ready = null\n throw err\n })\n }\n return ready\n }\n\n const upsert = async (doc: VectorDriverDocument) => {\n await ensureReady()\n const vectorLiteral = toVectorLiteral(doc.embedding)\n await pool.query(\n `\n INSERT INTO ${tableIdent} (\n driver_id, entity_id, record_id, tenant_id, organization_id, checksum,\n embedding, url, presenter, links, payload,\n result_title, result_subtitle, result_icon, result_badge, result_snapshot,\n primary_link_href, primary_link_label,\n created_at, updated_at\n )\n VALUES (\n $1, $2, $3, $4::uuid, $5::uuid, $6, $7::vector, $8, $9::jsonb, $10::jsonb, $11::jsonb,\n $12, $13, $14, $15, $16, $17, $18,\n now(), now()\n )\n ON CONFLICT (driver_id, entity_id, record_id, tenant_id)\n DO UPDATE SET\n organization_id = EXCLUDED.organization_id,\n checksum = EXCLUDED.checksum,\n embedding = EXCLUDED.embedding,\n url = EXCLUDED.url,\n presenter = EXCLUDED.presenter,\n links = EXCLUDED.links,\n payload = EXCLUDED.payload,\n result_title = EXCLUDED.result_title,\n result_subtitle = EXCLUDED.result_subtitle,\n result_icon = EXCLUDED.result_icon,\n result_badge = EXCLUDED.result_badge,\n result_snapshot = EXCLUDED.result_snapshot,\n primary_link_href = EXCLUDED.primary_link_href,\n primary_link_label = EXCLUDED.primary_link_label,\n updated_at = now()\n `,\n [\n doc.driverId ?? DRIVER_ID,\n doc.entityId,\n doc.recordId,\n doc.tenantId,\n doc.organizationId ?? null,\n doc.checksum,\n vectorLiteral,\n doc.url ?? null,\n doc.presenter ? JSON.stringify(doc.presenter) : null,\n doc.links ? JSON.stringify(doc.links) : null,\n doc.payload ? JSON.stringify(doc.payload) : null,\n doc.resultTitle,\n doc.resultSubtitle ?? null,\n doc.resultIcon ?? null,\n doc.resultBadge ?? null,\n doc.resultSnapshot ?? null,\n doc.primaryLinkHref ?? null,\n doc.primaryLinkLabel ?? null,\n ],\n )\n }\n\n const remove = async (entityId: string, recordId: string, tenantId: string) => {\n await ensureReady()\n await pool.query(\n `DELETE FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,\n [DRIVER_ID, entityId, recordId, tenantId],\n )\n }\n\n const getChecksum = async (entityId: string, recordId: string, tenantId: string): Promise<string | null> => {\n await ensureReady()\n const res = await pool.query<{ checksum: string }>(\n `SELECT checksum FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,\n [DRIVER_ID, entityId, recordId, tenantId],\n )\n return res.rowCount ? res.rows[0].checksum : null\n }\n\n const purge = async (entityId: string, tenantId: string, organizationId?: string | null) => {\n await ensureReady()\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n const conditions = ['driver_id = $1', 'entity_id = $2', 'tenant_id = $3::uuid']\n const values: any[] = [DRIVER_ID, entityId, tenantId]\n if (normalizedOrganizationId !== null) {\n conditions.push('organization_id = $4::uuid')\n values.push(normalizedOrganizationId)\n }\n await pool.query(\n `DELETE FROM ${tableIdent} WHERE ${conditions.join(' AND ')}`,\n values,\n )\n }\n\n const query = async (input: VectorDriverQuery): Promise<VectorDriverQueryResult[]> => {\n await ensureReady()\n const vectorLiteral = toVectorLiteral(input.vector)\n const filter = input.filter ?? { tenantId: '' }\n const normalizedOrganizationId =\n typeof filter.organizationId === 'string' && filter.organizationId.trim().length > 0\n ? filter.organizationId.trim()\n : null\n const normalizedOrganizationIds = normalizedOrganizationId\n ? [normalizedOrganizationId]\n : Array.isArray(filter.organizationIds)\n ? Array.from(new Set(\n filter.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n : null\n if (normalizedOrganizationIds && normalizedOrganizationIds.length === 0) {\n return []\n }\n const params: any[] = [\n vectorLiteral,\n DRIVER_ID,\n filter.tenantId,\n normalizedOrganizationIds,\n Array.isArray(filter.entityIds) && filter.entityIds.length ? filter.entityIds : null,\n input.limit ?? 20,\n ]\n const res = await pool.query<{\n entity_id: string\n record_id: string\n organization_id: string | null\n checksum: string\n url: string | null\n presenter: string | null\n links: string | null\n payload: string | null\n result_title: string | null\n result_subtitle: string | null\n result_icon: string | null\n result_badge: string | null\n result_snapshot: string | null\n primary_link_href: string | null\n primary_link_label: string | null\n distance: number\n }>(\n `\n SELECT\n entity_id,\n record_id,\n organization_id,\n checksum,\n url,\n presenter,\n links,\n payload,\n result_title,\n result_subtitle,\n result_icon,\n result_badge,\n result_snapshot,\n primary_link_href,\n primary_link_label,\n embedding <=> $1::vector AS distance\n FROM ${tableIdent}\n WHERE driver_id = $2\n AND tenant_id = $3::uuid\n AND ($4::uuid[] IS NULL OR organization_id = ANY($4::uuid[]))\n AND (\n $5::text[] IS NULL OR entity_id = ANY($5::text[])\n )\n ORDER BY embedding <=> $1::vector\n LIMIT $6\n `,\n params,\n )\n return res.rows.map<VectorDriverQueryResult>((row) => {\n const distance = typeof row.distance === 'number' ? row.distance : Number(row.distance || 1)\n const score = 1 - distance\n return {\n entityId: row.entity_id,\n recordId: row.record_id,\n organizationId: row.organization_id ?? null,\n checksum: row.checksum,\n url: row.url ?? null,\n presenter: parseJsonColumn<VectorResultPresenter>(row.presenter),\n links: parseJsonColumn<VectorLinkDescriptor[]>(row.links),\n payload: parseJsonColumn<Record<string, unknown>>(row.payload),\n resultTitle: row.result_title ?? '',\n resultSubtitle: row.result_subtitle ?? null,\n resultIcon: row.result_icon ?? null,\n resultBadge: row.result_badge ?? null,\n resultSnapshot: row.result_snapshot ?? null,\n primaryLinkHref: row.primary_link_href ?? null,\n primaryLinkLabel: row.primary_link_label ?? null,\n score,\n }\n })\n }\n\n const list = async (params: VectorDriverListParams): Promise<VectorIndexEntry[]> => {\n await ensureReady()\n const limit = Math.max(1, Math.min(params.limit ?? 50, 200))\n const offset = Math.max(0, params.offset ?? 0)\n const orderColumn = params.orderBy === 'created' ? 'created_at' : 'updated_at'\n const conditions: string[] = [\n 'driver_id = $1',\n 'tenant_id = $2::uuid',\n ]\n const values: any[] = [DRIVER_ID, params.tenantId]\n let nextParam = 3\n\n const normalizedOrganizationId =\n typeof params.organizationId === 'string' && params.organizationId.trim().length > 0\n ? params.organizationId.trim()\n : null\n if (normalizedOrganizationId !== null) {\n conditions.push(`organization_id = $${nextParam}::uuid`)\n values.push(normalizedOrganizationId)\n nextParam += 1\n }\n\n if (params.entityId) {\n conditions.push(`entity_id = $${nextParam}::text`)\n values.push(params.entityId)\n nextParam += 1\n }\n\n const limitParam = nextParam\n const offsetParam = nextParam + 1\n values.push(limit, offset)\n\n const sql = `\n SELECT\n entity_id,\n record_id,\n tenant_id,\n organization_id,\n checksum,\n url,\n presenter,\n links,\n payload,\n result_title,\n result_subtitle,\n result_icon,\n result_badge,\n result_snapshot,\n primary_link_href,\n primary_link_label,\n created_at,\n updated_at\n FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n ORDER BY ${orderColumn} DESC\n LIMIT $${limitParam} OFFSET $${offsetParam}\n `\n\n const res = await pool.query<{\n entity_id: string\n record_id: string\n tenant_id: string\n organization_id: string | null\n checksum: string\n url: string | null\n presenter: string | null\n links: string | null\n payload: string | null\n result_title: string | null\n result_subtitle: string | null\n result_icon: string | null\n result_badge: string | null\n result_snapshot: string | null\n primary_link_href: string | null\n primary_link_label: string | null\n created_at: Date | string\n updated_at: Date | string\n }>(sql, values)\n return res.rows.map<VectorIndexEntry>((row) => {\n const presenter = parseJsonColumn<VectorResultPresenter>(row.presenter)\n const links = parseJsonColumn<VectorLinkDescriptor[]>(row.links)\n const payload = parseJsonColumn<Record<string, unknown>>(row.payload)\n const createdAt =\n row.created_at instanceof Date\n ? row.created_at.toISOString()\n : new Date(row.created_at ?? Date.now()).toISOString()\n const updatedAt =\n row.updated_at instanceof Date\n ? row.updated_at.toISOString()\n : new Date(row.updated_at ?? Date.now()).toISOString()\n return {\n entityId: row.entity_id,\n recordId: row.record_id,\n driverId: DRIVER_ID,\n tenantId: row.tenant_id,\n organizationId: row.organization_id ?? null,\n checksum: row.checksum,\n url: row.url ?? null,\n presenter,\n links,\n payload,\n metadata: payload,\n resultTitle: row.result_title ?? '',\n resultSubtitle: row.result_subtitle ?? null,\n resultIcon: row.result_icon ?? null,\n resultBadge: row.result_badge ?? null,\n resultSnapshot: row.result_snapshot ?? null,\n primaryLinkHref: row.primary_link_href ?? null,\n primaryLinkLabel: row.primary_link_label ?? null,\n createdAt,\n updatedAt,\n score: null,\n }\n })\n }\n\n const count = async (params: VectorDriverCountParams): Promise<number> => {\n await ensureReady()\n const conditions: string[] = [\n 'driver_id = $1',\n 'tenant_id = $2::uuid',\n ]\n const values: any[] = [DRIVER_ID, params.tenantId]\n let nextParam = 3\n\n const normalizedOrganizationId =\n typeof params.organizationId === 'string' && params.organizationId.trim().length > 0\n ? params.organizationId.trim()\n : null\n if (normalizedOrganizationId !== null) {\n conditions.push(`organization_id = $${nextParam}::uuid`)\n values.push(normalizedOrganizationId)\n nextParam += 1\n }\n if (params.entityId) {\n conditions.push(`entity_id = $${nextParam}::text`)\n values.push(params.entityId)\n nextParam += 1\n }\n\n const sql = `\n SELECT count(*)::bigint AS total\n FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n `\n const res = await pool.query<{ total: string }>(sql, values)\n const raw = res.rows?.[0]?.total\n if (!raw) return 0\n const parsed = Number(raw)\n return Number.isFinite(parsed) ? parsed : 0\n }\n\n const removeOrphans = async (params: VectorDriverRemoveOrphansParams): Promise<number> => {\n await ensureReady()\n const conditions: string[] = [\n 'driver_id = $1',\n 'entity_id = $2',\n 'updated_at < $3::timestamptz',\n ]\n const values: any[] = [DRIVER_ID, params.entityId, (params.olderThan instanceof Date ? params.olderThan : new Date(params.olderThan)).toISOString()]\n let nextParam = 4\n\n if (params.tenantId !== undefined) {\n conditions.push(`tenant_id is not distinct from $${nextParam}::uuid`)\n values.push(params.tenantId)\n nextParam += 1\n }\n\n if (params.organizationId !== undefined) {\n conditions.push(`organization_id is not distinct from $${nextParam}::uuid`)\n values.push(params.organizationId)\n nextParam += 1\n }\n\n const sql = `\n DELETE FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n `\n const res = await pool.query(sql, values)\n return res.rowCount ?? 0\n }\n\n const getTableDimension = async (): Promise<number | null> => {\n try {\n const res = await pool.query<{ atttypmod: number }>(\n `SELECT a.atttypmod\n FROM pg_attribute a\n JOIN pg_class c ON a.attrelid = c.oid\n WHERE c.relname = $1\n AND a.attname = 'embedding'\n AND a.atttypmod > 0`,\n [tableName]\n )\n if (res.rows.length > 0 && res.rows[0].atttypmod > 0) {\n return res.rows[0].atttypmod\n }\n return null\n } catch {\n return null\n }\n }\n\n const recreateWithDimension = async (newDimension: number): Promise<void> => {\n await withClient(pool, async (client) => {\n await client.query(`DROP TABLE IF EXISTS ${tableIdent} CASCADE`)\n await client.query(`DROP TABLE IF EXISTS ${migrationsIdent} CASCADE`)\n })\n ready = null\n dimension = newDimension\n await ensureReady()\n }\n\n return {\n id: 'pgvector',\n ensureReady,\n upsert,\n delete: remove,\n getChecksum,\n purge,\n query,\n list,\n count,\n removeOrphans,\n getTableDimension,\n recreateWithDimension,\n }\n}\n"],
4
+ "sourcesContent": ["import { Pool } from 'pg'\nimport { searchDebugWarn } from '../../../lib/debug'\n\ntype PgPoolQueryResult<T> = { rows: T[]; rowCount?: number }\ntype PgPoolClient = {\n query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>\n release(): void\n}\nexport type PgPool = {\n connect(): Promise<PgPoolClient>\n query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>\n end(): Promise<void>\n}\nimport type {\n VectorDriver,\n VectorDriverDocument,\n VectorDriverQuery,\n VectorDriverQueryResult,\n VectorDriverListParams,\n VectorDriverCountParams,\n VectorIndexEntry,\n VectorDriverRemoveOrphansParams,\n VectorResultPresenter,\n VectorLinkDescriptor,\n} from '../../types'\n\ntype PgVectorDriverOptions = {\n pool?: PgPool\n connectionString?: string\n tableName?: string\n migrationsTable?: string\n dimension?: number\n distanceMetric?: 'cosine' | 'euclidean' | 'inner'\n}\n\nconst DEFAULT_TABLE = 'vector_search'\nconst DEFAULT_MIGRATIONS_TABLE = 'vector_search_migrations'\nconst DEFAULT_DIMENSION = 1536\nconst DRIVER_ID = 'pgvector' as const\n\nfunction assertIdentifier(name: string, defaultName: string): string {\n const candidate = name ?? defaultName\n if (!candidate || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(candidate)) return defaultName\n return candidate\n}\n\nfunction quoteIdent(name: string): string {\n return `\"${name}\"`\n}\n\nfunction toVectorLiteral(values: number[]): string {\n const formatted = values.map((n) => {\n if (!Number.isFinite(n)) return '0'\n const rounded = Math.fround(n)\n return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`\n })\n return `[${formatted.join(',')}]`\n}\n\nfunction parseJsonColumn<T>(value: unknown): T | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'string') {\n try {\n return JSON.parse(value) as T\n } catch {\n // When `jsonb` stores a JSON string, node-postgres parses it into a plain JS string.\n // In that case, there is nothing to JSON.parse \u2014 return the raw string value.\n return value as unknown as T\n }\n }\n if (typeof value === 'object') {\n return value as T\n }\n return null\n}\n\nasync function withClient<T>(pool: PgPool, fn: (client: PgPoolClient) => Promise<T>): Promise<T> {\n const client = await pool.connect()\n try {\n return await fn(client)\n } finally {\n client.release()\n }\n}\n\nexport function createPgVectorDriver(opts: PgVectorDriverOptions = {}): VectorDriver {\n const tableName = assertIdentifier(opts.tableName ?? DEFAULT_TABLE, DEFAULT_TABLE)\n const migrationsTable = assertIdentifier(opts.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE, DEFAULT_MIGRATIONS_TABLE)\n let dimension = opts.dimension ?? DEFAULT_DIMENSION\n const distanceMetric = opts.distanceMetric ?? 'cosine'\n const tableIdent = quoteIdent(tableName)\n const migrationsIdent = quoteIdent(migrationsTable)\n\n const pool: PgPool =\n opts.pool ??\n (() => {\n const conn = opts.connectionString ?? process.env.DATABASE_URL\n if (!conn) {\n throw new Error('[vector.pgvector] DATABASE_URL is not configured')\n }\n return new Pool({ connectionString: conn }) as unknown as PgPool\n })()\n\n let ready: Promise<void> | null = null\n\n const ensureReady = async () => {\n if (!ready) {\n ready = withClient(pool, async (client) => {\n const ensureExtension = async (extension: 'pgcrypto' | 'vector') => {\n try {\n await client.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`)\n } catch (error) {\n const pgError = error as { code?: string; message?: string }\n if (pgError?.code === '42501') {\n const details = pgError.message ? ` (${pgError.message})` : ''\n searchDebugWarn('vector.pgvector', `skipping ${extension} extension creation; requires superuser${details}`)\n return\n }\n throw error\n }\n }\n\n await ensureExtension('pgcrypto')\n await ensureExtension('vector')\n\n await client.query(\n `CREATE TABLE IF NOT EXISTS ${migrationsIdent} (\n id text primary key,\n applied_at timestamptz not null default now()\n )`,\n )\n\n await client.query(\n `CREATE TABLE IF NOT EXISTS ${tableIdent} (\n id uuid primary key default gen_random_uuid(),\n driver_id text not null,\n entity_id text not null,\n record_id text not null,\n tenant_id uuid not null,\n organization_id uuid null,\n checksum text not null,\n embedding vector(${dimension}) not null,\n url text null,\n presenter jsonb null,\n links jsonb null,\n payload jsonb null,\n result_title text null,\n result_subtitle text null,\n result_icon text null,\n result_badge text null,\n result_snapshot text null,\n primary_link_href text null,\n primary_link_label text null,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n )`,\n )\n\n await client.query(\n `CREATE UNIQUE INDEX IF NOT EXISTS ${tableName}_uniq ON ${tableIdent} (driver_id, entity_id, record_id, tenant_id)`,\n )\n await client.query(\n `CREATE INDEX IF NOT EXISTS ${tableName}_lookup ON ${tableIdent} (tenant_id, organization_id, entity_id)`,\n )\n // ivfflat index only supports up to 2000 dimensions\n // For higher dimensions, skip the index (uses sequential scan, slower but works)\n // Also check actual table dimension in case driver was initialized with different value\n let actualDimension = dimension\n try {\n const dimResult = await client.query<{ atttypmod: number }>(\n `SELECT a.atttypmod\n FROM pg_attribute a\n JOIN pg_class c ON a.attrelid = c.oid\n WHERE c.relname = $1\n AND a.attname = 'embedding'\n AND a.atttypmod > 0`,\n [tableName]\n )\n if (dimResult.rows.length > 0 && dimResult.rows[0].atttypmod > 0) {\n actualDimension = dimResult.rows[0].atttypmod\n }\n } catch {\n // Ignore errors reading dimension, use configured value\n }\n\n if (actualDimension <= 2000) {\n try {\n await client.query(\n `CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx ON ${tableIdent}\n USING ivfflat (embedding vector_${distanceMetric}_ops) WITH (lists = 100)`,\n )\n } catch (indexErr: unknown) {\n // Handle case where dimension exceeds ivfflat limit\n const errorMessage = indexErr instanceof Error ? indexErr.message : String(indexErr)\n if (errorMessage.includes('2000 dimensions')) {\n searchDebugWarn('pgvector', 'Skipping ivfflat index - dimension exceeds 2000 limit. Searches will use sequential scan.')\n } else {\n throw indexErr\n }\n }\n } else {\n searchDebugWarn('pgvector', `Skipping ivfflat index - dimension ${actualDimension} exceeds 2000 limit. Searches will use sequential scan.`)\n }\n\n const columnAlters = [\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_title text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_subtitle text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_icon text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_badge text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_snapshot text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_href text`,\n `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_label text`,\n ]\n for (const statement of columnAlters) {\n await client.query(statement)\n }\n\n await client.query(\n `INSERT INTO ${migrationsIdent} (id, applied_at) VALUES ($1, now()) ON CONFLICT (id) DO NOTHING`,\n ['0001_init'],\n )\n }).catch((err) => {\n ready = null\n throw err\n })\n }\n return ready\n }\n\n const upsert = async (doc: VectorDriverDocument) => {\n await ensureReady()\n const vectorLiteral = toVectorLiteral(doc.embedding)\n await pool.query(\n `\n INSERT INTO ${tableIdent} (\n driver_id, entity_id, record_id, tenant_id, organization_id, checksum,\n embedding, url, presenter, links, payload,\n result_title, result_subtitle, result_icon, result_badge, result_snapshot,\n primary_link_href, primary_link_label,\n created_at, updated_at\n )\n VALUES (\n $1, $2, $3, $4::uuid, $5::uuid, $6, $7::vector, $8, $9::jsonb, $10::jsonb, $11::jsonb,\n $12, $13, $14, $15, $16, $17, $18,\n now(), now()\n )\n ON CONFLICT (driver_id, entity_id, record_id, tenant_id)\n DO UPDATE SET\n organization_id = EXCLUDED.organization_id,\n checksum = EXCLUDED.checksum,\n embedding = EXCLUDED.embedding,\n url = EXCLUDED.url,\n presenter = EXCLUDED.presenter,\n links = EXCLUDED.links,\n payload = EXCLUDED.payload,\n result_title = EXCLUDED.result_title,\n result_subtitle = EXCLUDED.result_subtitle,\n result_icon = EXCLUDED.result_icon,\n result_badge = EXCLUDED.result_badge,\n result_snapshot = EXCLUDED.result_snapshot,\n primary_link_href = EXCLUDED.primary_link_href,\n primary_link_label = EXCLUDED.primary_link_label,\n updated_at = now()\n `,\n [\n doc.driverId ?? DRIVER_ID,\n doc.entityId,\n doc.recordId,\n doc.tenantId,\n doc.organizationId ?? null,\n doc.checksum,\n vectorLiteral,\n doc.url ?? null,\n doc.presenter ? JSON.stringify(doc.presenter) : null,\n doc.links ? JSON.stringify(doc.links) : null,\n doc.payload ? JSON.stringify(doc.payload) : null,\n doc.resultTitle,\n doc.resultSubtitle ?? null,\n doc.resultIcon ?? null,\n doc.resultBadge ?? null,\n doc.resultSnapshot ?? null,\n doc.primaryLinkHref ?? null,\n doc.primaryLinkLabel ?? null,\n ],\n )\n }\n\n const remove = async (entityId: string, recordId: string, tenantId: string) => {\n await ensureReady()\n await pool.query(\n `DELETE FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,\n [DRIVER_ID, entityId, recordId, tenantId],\n )\n }\n\n const getChecksum = async (entityId: string, recordId: string, tenantId: string): Promise<string | null> => {\n await ensureReady()\n const res = await pool.query<{ checksum: string }>(\n `SELECT checksum FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,\n [DRIVER_ID, entityId, recordId, tenantId],\n )\n return res.rowCount ? res.rows[0].checksum : null\n }\n\n const purge = async (entityId: string, tenantId: string, organizationId?: string | null) => {\n await ensureReady()\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n const conditions = ['driver_id = $1', 'entity_id = $2', 'tenant_id = $3::uuid']\n const values: any[] = [DRIVER_ID, entityId, tenantId]\n if (normalizedOrganizationId !== null) {\n conditions.push('organization_id = $4::uuid')\n values.push(normalizedOrganizationId)\n }\n await pool.query(\n `DELETE FROM ${tableIdent} WHERE ${conditions.join(' AND ')}`,\n values,\n )\n }\n\n const query = async (input: VectorDriverQuery): Promise<VectorDriverQueryResult[]> => {\n await ensureReady()\n const vectorLiteral = toVectorLiteral(input.vector)\n const filter = input.filter ?? { tenantId: '' }\n const normalizedOrganizationId =\n typeof filter.organizationId === 'string' && filter.organizationId.trim().length > 0\n ? filter.organizationId.trim()\n : null\n const normalizedOrganizationIds = normalizedOrganizationId\n ? [normalizedOrganizationId]\n : Array.isArray(filter.organizationIds)\n ? Array.from(new Set(\n filter.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n : null\n if (normalizedOrganizationIds && normalizedOrganizationIds.length === 0) {\n return []\n }\n const params: any[] = [\n vectorLiteral,\n DRIVER_ID,\n filter.tenantId,\n normalizedOrganizationIds,\n Array.isArray(filter.entityIds) && filter.entityIds.length ? filter.entityIds : null,\n input.limit ?? 20,\n ]\n const res = await pool.query<{\n entity_id: string\n record_id: string\n organization_id: string | null\n checksum: string\n url: string | null\n presenter: string | null\n links: string | null\n payload: string | null\n result_title: string | null\n result_subtitle: string | null\n result_icon: string | null\n result_badge: string | null\n result_snapshot: string | null\n primary_link_href: string | null\n primary_link_label: string | null\n distance: number\n }>(\n `\n SELECT\n entity_id,\n record_id,\n organization_id,\n checksum,\n url,\n presenter,\n links,\n payload,\n result_title,\n result_subtitle,\n result_icon,\n result_badge,\n result_snapshot,\n primary_link_href,\n primary_link_label,\n embedding <=> $1::vector AS distance\n FROM ${tableIdent}\n WHERE driver_id = $2\n AND tenant_id = $3::uuid\n AND ($4::uuid[] IS NULL OR organization_id = ANY($4::uuid[]))\n AND (\n $5::text[] IS NULL OR entity_id = ANY($5::text[])\n )\n ORDER BY embedding <=> $1::vector\n LIMIT $6\n `,\n params,\n )\n return res.rows.map<VectorDriverQueryResult>((row) => {\n const distance = typeof row.distance === 'number' ? row.distance : Number(row.distance || 1)\n const score = 1 - distance\n return {\n entityId: row.entity_id,\n recordId: row.record_id,\n organizationId: row.organization_id ?? null,\n checksum: row.checksum,\n url: row.url ?? null,\n presenter: parseJsonColumn<VectorResultPresenter>(row.presenter),\n links: parseJsonColumn<VectorLinkDescriptor[]>(row.links),\n payload: parseJsonColumn<Record<string, unknown>>(row.payload),\n resultTitle: row.result_title ?? '',\n resultSubtitle: row.result_subtitle ?? null,\n resultIcon: row.result_icon ?? null,\n resultBadge: row.result_badge ?? null,\n resultSnapshot: row.result_snapshot ?? null,\n primaryLinkHref: row.primary_link_href ?? null,\n primaryLinkLabel: row.primary_link_label ?? null,\n score,\n }\n })\n }\n\n const list = async (params: VectorDriverListParams): Promise<VectorIndexEntry[]> => {\n await ensureReady()\n const limit = Math.max(1, Math.min(params.limit ?? 50, 200))\n const offset = Math.max(0, params.offset ?? 0)\n const orderColumn = params.orderBy === 'created' ? 'created_at' : 'updated_at'\n const conditions: string[] = [\n 'driver_id = $1',\n 'tenant_id = $2::uuid',\n ]\n const values: any[] = [DRIVER_ID, params.tenantId]\n let nextParam = 3\n\n const normalizedOrganizationId =\n typeof params.organizationId === 'string' && params.organizationId.trim().length > 0\n ? params.organizationId.trim()\n : null\n if (normalizedOrganizationId !== null) {\n conditions.push(`organization_id = $${nextParam}::uuid`)\n values.push(normalizedOrganizationId)\n nextParam += 1\n }\n\n if (params.entityId) {\n conditions.push(`entity_id = $${nextParam}::text`)\n values.push(params.entityId)\n nextParam += 1\n }\n\n const limitParam = nextParam\n const offsetParam = nextParam + 1\n values.push(limit, offset)\n\n const sql = `\n SELECT\n entity_id,\n record_id,\n tenant_id,\n organization_id,\n checksum,\n url,\n presenter,\n links,\n payload,\n result_title,\n result_subtitle,\n result_icon,\n result_badge,\n result_snapshot,\n primary_link_href,\n primary_link_label,\n created_at,\n updated_at\n FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n ORDER BY ${orderColumn} DESC\n LIMIT $${limitParam} OFFSET $${offsetParam}\n `\n\n const res = await pool.query<{\n entity_id: string\n record_id: string\n tenant_id: string\n organization_id: string | null\n checksum: string\n url: string | null\n presenter: string | null\n links: string | null\n payload: string | null\n result_title: string | null\n result_subtitle: string | null\n result_icon: string | null\n result_badge: string | null\n result_snapshot: string | null\n primary_link_href: string | null\n primary_link_label: string | null\n created_at: Date | string\n updated_at: Date | string\n }>(sql, values)\n return res.rows.map<VectorIndexEntry>((row) => {\n const presenter = parseJsonColumn<VectorResultPresenter>(row.presenter)\n const links = parseJsonColumn<VectorLinkDescriptor[]>(row.links)\n const payload = parseJsonColumn<Record<string, unknown>>(row.payload)\n const createdAt =\n row.created_at instanceof Date\n ? row.created_at.toISOString()\n : new Date(row.created_at ?? Date.now()).toISOString()\n const updatedAt =\n row.updated_at instanceof Date\n ? row.updated_at.toISOString()\n : new Date(row.updated_at ?? Date.now()).toISOString()\n return {\n entityId: row.entity_id,\n recordId: row.record_id,\n driverId: DRIVER_ID,\n tenantId: row.tenant_id,\n organizationId: row.organization_id ?? null,\n checksum: row.checksum,\n url: row.url ?? null,\n presenter,\n links,\n payload,\n metadata: payload,\n resultTitle: row.result_title ?? '',\n resultSubtitle: row.result_subtitle ?? null,\n resultIcon: row.result_icon ?? null,\n resultBadge: row.result_badge ?? null,\n resultSnapshot: row.result_snapshot ?? null,\n primaryLinkHref: row.primary_link_href ?? null,\n primaryLinkLabel: row.primary_link_label ?? null,\n createdAt,\n updatedAt,\n score: null,\n }\n })\n }\n\n const count = async (params: VectorDriverCountParams): Promise<number> => {\n await ensureReady()\n const conditions: string[] = [\n 'driver_id = $1',\n 'tenant_id = $2::uuid',\n ]\n const values: any[] = [DRIVER_ID, params.tenantId]\n let nextParam = 3\n\n const normalizedOrganizationId =\n typeof params.organizationId === 'string' && params.organizationId.trim().length > 0\n ? params.organizationId.trim()\n : null\n if (normalizedOrganizationId !== null) {\n conditions.push(`organization_id = $${nextParam}::uuid`)\n values.push(normalizedOrganizationId)\n nextParam += 1\n }\n if (params.entityId) {\n conditions.push(`entity_id = $${nextParam}::text`)\n values.push(params.entityId)\n nextParam += 1\n }\n\n const sql = `\n SELECT count(*)::bigint AS total\n FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n `\n const res = await pool.query<{ total: string }>(sql, values)\n const raw = res.rows?.[0]?.total\n if (!raw) return 0\n const parsed = Number(raw)\n return Number.isFinite(parsed) ? parsed : 0\n }\n\n const removeOrphans = async (params: VectorDriverRemoveOrphansParams): Promise<number> => {\n await ensureReady()\n const conditions: string[] = [\n 'driver_id = $1',\n 'entity_id = $2',\n 'updated_at < $3::timestamptz',\n ]\n const values: any[] = [DRIVER_ID, params.entityId, (params.olderThan instanceof Date ? params.olderThan : new Date(params.olderThan)).toISOString()]\n let nextParam = 4\n\n if (params.tenantId !== undefined) {\n conditions.push(`tenant_id is not distinct from $${nextParam}::uuid`)\n values.push(params.tenantId)\n nextParam += 1\n }\n\n if (params.organizationId !== undefined) {\n conditions.push(`organization_id is not distinct from $${nextParam}::uuid`)\n values.push(params.organizationId)\n nextParam += 1\n }\n\n const sql = `\n DELETE FROM ${tableIdent}\n WHERE ${conditions.join('\\n AND ')}\n `\n const res = await pool.query(sql, values)\n return res.rowCount ?? 0\n }\n\n const getTableDimension = async (): Promise<number | null> => {\n try {\n const res = await pool.query<{ atttypmod: number }>(\n `SELECT a.atttypmod\n FROM pg_attribute a\n JOIN pg_class c ON a.attrelid = c.oid\n WHERE c.relname = $1\n AND a.attname = 'embedding'\n AND a.atttypmod > 0`,\n [tableName]\n )\n if (res.rows.length > 0 && res.rows[0].atttypmod > 0) {\n return res.rows[0].atttypmod\n }\n return null\n } catch {\n return null\n }\n }\n\n const recreateWithDimension = async (newDimension: number): Promise<void> => {\n await withClient(pool, async (client) => {\n await client.query(`DROP TABLE IF EXISTS ${tableIdent} CASCADE`)\n await client.query(`DROP TABLE IF EXISTS ${migrationsIdent} CASCADE`)\n })\n ready = null\n dimension = newDimension\n await ensureReady()\n }\n\n return {\n id: 'pgvector',\n ensureReady,\n upsert,\n delete: remove,\n getChecksum,\n purge,\n query,\n list,\n count,\n removeOrphans,\n getTableDimension,\n recreateWithDimension,\n }\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -39,6 +39,9 @@ class EmbeddingService {
39
39
  }
40
40
  }
41
41
  updateConfig(config) {
42
+ if (config.providerId === this.config.providerId && config.model === this.config.model && config.dimension === this.config.dimension && config.outputDimensionality === this.config.outputDimensionality && config.baseUrl === this.config.baseUrl) {
43
+ return;
44
+ }
42
45
  this.config = config;
43
46
  this.clientCache.clear();
44
47
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/vector/services/embedding.ts"],
4
- "sourcesContent": ["import { embed } from 'ai'\nimport type { EmbeddingModel } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\n\n// Local type definition to avoid @ai-sdk/provider version conflicts\n// Matches SharedV3ProviderOptions = Record<string, JSONObject>\ntype JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }\ntype JSONObject = { [key: string]: JSONValue }\ntype ProviderOptions = Record<string, JSONObject>\nimport { createGoogleGenerativeAI } from '@ai-sdk/google'\nimport { createMistral } from '@ai-sdk/mistral'\nimport { createCohere } from '@ai-sdk/cohere'\nimport { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'\nimport { createOllama } from 'ai-sdk-ollama'\nimport type { EmbeddingProviderId, EmbeddingProviderConfig } from '../types'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../types'\nimport { assertSafeOllamaBaseUrl, UnsafeOllamaBaseUrlError } from '../lib/ollama-url-safety'\n\nexport type EmbeddingServiceOptions = {\n apiKey?: string\n model?: string\n config?: EmbeddingProviderConfig\n}\n\ntype OllamaClient = ReturnType<typeof createOllama>\n\ntype ProviderClient = ReturnType<typeof createOpenAI>\n | ReturnType<typeof createGoogleGenerativeAI>\n | ReturnType<typeof createMistral>\n | ReturnType<typeof createCohere>\n | ReturnType<typeof createAmazonBedrock>\n | OllamaClient\n\nconst DEFAULT_EMBEDDING_TIMEOUT_MS = 3_000\n\nfunction resolveEmbeddingTimeoutMs(): number {\n const rawValue = process.env.VECTOR_EMBEDDING_TIMEOUT_MS\n if (!rawValue) return DEFAULT_EMBEDDING_TIMEOUT_MS\n const parsed = Number.parseInt(rawValue, 10)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_EMBEDDING_TIMEOUT_MS\n }\n return parsed\n}\n\nfunction timeoutError(providerId: EmbeddingProviderId, timeoutMs: number): Error {\n const providerInfo = EMBEDDING_PROVIDERS[providerId]\n return new Error(\n `${providerInfo.name} request timed out after ${timeoutMs}ms. Check ${providerInfo.envKeyRequired}.`,\n )\n}\n\nexport class EmbeddingService {\n private config: EmbeddingProviderConfig\n private clientCache: Map<EmbeddingProviderId, ProviderClient> = new Map()\n\n constructor(private readonly opts: EmbeddingServiceOptions = {}) {\n if (opts.config) {\n this.config = opts.config\n } else {\n this.config = {\n providerId: 'openai',\n model: opts.model ?? DEFAULT_EMBEDDING_CONFIG.model,\n dimension: DEFAULT_EMBEDDING_CONFIG.dimension,\n updatedAt: new Date().toISOString(),\n }\n }\n }\n\n updateConfig(config: EmbeddingProviderConfig): void {\n this.config = config\n this.clientCache.clear()\n }\n\n get currentConfig(): EmbeddingProviderConfig {\n return { ...this.config }\n }\n\n get dimension(): number {\n return this.config.outputDimensionality ?? this.config.dimension\n }\n\n get available(): boolean {\n return this.isProviderConfigured(this.config.providerId)\n }\n\n private isProviderConfigured(providerId: EmbeddingProviderId): boolean {\n switch (providerId) {\n case 'openai':\n return Boolean(this.opts.apiKey ?? process.env.OPENAI_API_KEY)\n case 'google':\n return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY)\n case 'mistral':\n return Boolean(process.env.MISTRAL_API_KEY)\n case 'cohere':\n return Boolean(process.env.COHERE_API_KEY)\n case 'bedrock':\n return Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)\n case 'ollama':\n return true\n default:\n return false\n }\n }\n\n private getClient(providerId: EmbeddingProviderId): ProviderClient {\n const cached = this.clientCache.get(providerId)\n if (cached) {\n return cached\n }\n\n let client: ProviderClient\n switch (providerId) {\n case 'openai': {\n const apiKey = this.opts.apiKey ?? process.env.OPENAI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing OPENAI_API_KEY environment variable')\n }\n client = createOpenAI({ apiKey })\n break\n }\n case 'google': {\n const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing GOOGLE_GENERATIVE_AI_API_KEY environment variable')\n }\n client = createGoogleGenerativeAI({ apiKey })\n break\n }\n case 'mistral': {\n const apiKey = process.env.MISTRAL_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing MISTRAL_API_KEY environment variable')\n }\n client = createMistral({ apiKey })\n break\n }\n case 'cohere': {\n const apiKey = process.env.COHERE_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing COHERE_API_KEY environment variable')\n }\n client = createCohere({ apiKey })\n break\n }\n case 'bedrock': {\n const accessKeyId = process.env.AWS_ACCESS_KEY_ID\n const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY\n if (!accessKeyId || !secretAccessKey) {\n throw new Error('[vector.embedding] Missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY environment variables')\n }\n client = createAmazonBedrock({\n accessKeyId,\n secretAccessKey,\n region: process.env.AWS_REGION ?? 'us-east-1',\n })\n break\n }\n case 'ollama': {\n const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n try {\n assertSafeOllamaBaseUrl(baseURL)\n } catch (err) {\n if (err instanceof UnsafeOllamaBaseUrlError) {\n throw new Error(\n `[vector.embedding] Ollama base URL rejected (${err.reason}). Set OLLAMA_BASE_URL or OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.`,\n )\n }\n throw err\n }\n client = createOllama({ baseURL })\n break\n }\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n\n this.clientCache.set(providerId, client)\n return client\n }\n\n private getEmbeddingModel() {\n const client = this.getClient(this.config.providerId)\n const { providerId, model, outputDimensionality } = this.config\n\n switch (providerId) {\n case 'openai':\n return (client as ReturnType<typeof createOpenAI>).embedding(model)\n case 'google':\n return (client as ReturnType<typeof createGoogleGenerativeAI>).textEmbeddingModel(model)\n case 'mistral':\n return (client as ReturnType<typeof createMistral>).textEmbeddingModel(model)\n case 'cohere':\n return (client as ReturnType<typeof createCohere>).textEmbeddingModel(model)\n case 'bedrock':\n return (client as ReturnType<typeof createAmazonBedrock>).embedding(model)\n case 'ollama':\n return (client as OllamaClient).embedding(model)\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n }\n\n private getProviderOptions(): ProviderOptions | undefined {\n const { providerId, outputDimensionality, model } = this.config\n\n if (!outputDimensionality) {\n if (providerId === 'cohere') {\n return { cohere: { inputType: 'search_document' } }\n }\n return undefined\n }\n\n switch (providerId) {\n case 'openai':\n if (model === 'text-embedding-3-large' || model === 'text-embedding-3-small') {\n return { openai: { dimensions: outputDimensionality } }\n }\n return undefined\n case 'google':\n return { google: { outputDimensionality } }\n case 'bedrock':\n return { bedrock: { dimensions: outputDimensionality } }\n case 'cohere':\n return { cohere: { inputType: 'search_document' } }\n default:\n return undefined\n }\n }\n\n async createEmbedding(input: string | string[]): Promise<number[]> {\n const merged = Array.isArray(input)\n ? input.map((part) => String(part ?? '')).filter((part) => part.length > 0).join('\\n\\n')\n : String(input ?? '')\n if (!merged.length) {\n throw new Error('[vector.embedding] Refusing to embed empty payload')\n }\n\n if (!this.available) {\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n throw new Error(`[vector.embedding] Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`)\n }\n\n const model = this.getEmbeddingModel() as EmbeddingModel\n const providerOptions = this.getProviderOptions()\n const timeoutMs = resolveEmbeddingTimeoutMs()\n\n const abortController = new AbortController()\n let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n try {\n const result = await Promise.race([\n embed({\n model,\n value: merged,\n abortSignal: abortController.signal,\n ...(providerOptions && { providerOptions }),\n }),\n new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(\n () => {\n // Abort the in-flight request so a dead/unreachable provider releases\n // its socket \u2014 and, in a worker, the per-job DB connection held while\n // this awaits \u2014 promptly, instead of lingering until the platform's\n // default network timeout and pinning pool capacity under a storm.\n abortController.abort()\n reject(timeoutError(this.config.providerId, timeoutMs))\n },\n timeoutMs,\n )\n }),\n ])\n const emb = Array.isArray(result.embedding)\n ? result.embedding\n : Array.from(result.embedding as ArrayLike<number>)\n return emb.map((n) => Number.isFinite(n) ? Number(n) : 0)\n } catch (err: unknown) {\n const error = err as { statusCode?: number; status?: number; response?: { status?: number; statusCode?: number; data?: { error?: { message?: string; code?: string }; message?: string } }; data?: { error?: { message?: string; code?: string } }; body?: { error?: { message?: string; code?: string } }; message?: string }\n const statusCandidate =\n error?.statusCode ?? error?.status ?? error?.response?.status ?? error?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = error?.data?.error ?? error?.body?.error ?? error?.response?.data?.error\n const apiMessage = apiError?.message ?? error?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof error?.message === 'string' ? error.message : 'Embedding request failed')\n\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = `${providerInfo.name} usage quota exceeded. Please review your plan and billing.`\n break\n case 'invalid_api_key':\n guidance = `Invalid ${providerInfo.name} API key. Update the key and retry.`\n break\n case 'account_deactivated':\n guidance = `${providerInfo.name} account is disabled. Contact support or provide a different key.`\n break\n default:\n guidance = rawMessage.startsWith('[vector.embedding] ')\n ? rawMessage.slice('[vector.embedding] '.length)\n : rawMessage.includes('https://')\n ? rawMessage\n : rawMessage.includes(providerInfo.envKeyRequired)\n ? rawMessage\n : `${rawMessage}. Check ${providerInfo.envKeyRequired}.`\n }\n const wrapped = new Error(`[vector.embedding] ${guidance}`) as Error & { status?: number; code?: string; cause?: unknown }\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n wrapped.status = normalizedStatus\n }\n }\n if (apiCode) {\n wrapped.code = apiCode\n }\n wrapped.cause = err\n throw wrapped\n } finally {\n if (timeoutHandle !== null) {\n clearTimeout(timeoutHandle)\n }\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { embed } from 'ai'\nimport type { EmbeddingModel } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\n\n// Local type definition to avoid @ai-sdk/provider version conflicts\n// Matches SharedV3ProviderOptions = Record<string, JSONObject>\ntype JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }\ntype JSONObject = { [key: string]: JSONValue }\ntype ProviderOptions = Record<string, JSONObject>\nimport { createGoogleGenerativeAI } from '@ai-sdk/google'\nimport { createMistral } from '@ai-sdk/mistral'\nimport { createCohere } from '@ai-sdk/cohere'\nimport { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'\nimport { createOllama } from 'ai-sdk-ollama'\nimport type { EmbeddingProviderId, EmbeddingProviderConfig } from '../types'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../types'\nimport { assertSafeOllamaBaseUrl, UnsafeOllamaBaseUrlError } from '../lib/ollama-url-safety'\n\nexport type EmbeddingServiceOptions = {\n apiKey?: string\n model?: string\n config?: EmbeddingProviderConfig\n}\n\ntype OllamaClient = ReturnType<typeof createOllama>\n\ntype ProviderClient = ReturnType<typeof createOpenAI>\n | ReturnType<typeof createGoogleGenerativeAI>\n | ReturnType<typeof createMistral>\n | ReturnType<typeof createCohere>\n | ReturnType<typeof createAmazonBedrock>\n | OllamaClient\n\nconst DEFAULT_EMBEDDING_TIMEOUT_MS = 3_000\n\nfunction resolveEmbeddingTimeoutMs(): number {\n const rawValue = process.env.VECTOR_EMBEDDING_TIMEOUT_MS\n if (!rawValue) return DEFAULT_EMBEDDING_TIMEOUT_MS\n const parsed = Number.parseInt(rawValue, 10)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_EMBEDDING_TIMEOUT_MS\n }\n return parsed\n}\n\nfunction timeoutError(providerId: EmbeddingProviderId, timeoutMs: number): Error {\n const providerInfo = EMBEDDING_PROVIDERS[providerId]\n return new Error(\n `${providerInfo.name} request timed out after ${timeoutMs}ms. Check ${providerInfo.envKeyRequired}.`,\n )\n}\n\nexport class EmbeddingService {\n private config: EmbeddingProviderConfig\n private clientCache: Map<EmbeddingProviderId, ProviderClient> = new Map()\n\n constructor(private readonly opts: EmbeddingServiceOptions = {}) {\n if (opts.config) {\n this.config = opts.config\n } else {\n this.config = {\n providerId: 'openai',\n model: opts.model ?? DEFAULT_EMBEDDING_CONFIG.model,\n dimension: DEFAULT_EMBEDDING_CONFIG.dimension,\n updatedAt: new Date().toISOString(),\n }\n }\n }\n\n updateConfig(config: EmbeddingProviderConfig): void {\n if (\n config.providerId === this.config.providerId &&\n config.model === this.config.model &&\n config.dimension === this.config.dimension &&\n config.outputDimensionality === this.config.outputDimensionality &&\n config.baseUrl === this.config.baseUrl\n ) {\n return\n }\n this.config = config\n this.clientCache.clear()\n }\n\n get currentConfig(): EmbeddingProviderConfig {\n return { ...this.config }\n }\n\n get dimension(): number {\n return this.config.outputDimensionality ?? this.config.dimension\n }\n\n get available(): boolean {\n return this.isProviderConfigured(this.config.providerId)\n }\n\n private isProviderConfigured(providerId: EmbeddingProviderId): boolean {\n switch (providerId) {\n case 'openai':\n return Boolean(this.opts.apiKey ?? process.env.OPENAI_API_KEY)\n case 'google':\n return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY)\n case 'mistral':\n return Boolean(process.env.MISTRAL_API_KEY)\n case 'cohere':\n return Boolean(process.env.COHERE_API_KEY)\n case 'bedrock':\n return Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)\n case 'ollama':\n return true\n default:\n return false\n }\n }\n\n private getClient(providerId: EmbeddingProviderId): ProviderClient {\n const cached = this.clientCache.get(providerId)\n if (cached) {\n return cached\n }\n\n let client: ProviderClient\n switch (providerId) {\n case 'openai': {\n const apiKey = this.opts.apiKey ?? process.env.OPENAI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing OPENAI_API_KEY environment variable')\n }\n client = createOpenAI({ apiKey })\n break\n }\n case 'google': {\n const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing GOOGLE_GENERATIVE_AI_API_KEY environment variable')\n }\n client = createGoogleGenerativeAI({ apiKey })\n break\n }\n case 'mistral': {\n const apiKey = process.env.MISTRAL_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing MISTRAL_API_KEY environment variable')\n }\n client = createMistral({ apiKey })\n break\n }\n case 'cohere': {\n const apiKey = process.env.COHERE_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing COHERE_API_KEY environment variable')\n }\n client = createCohere({ apiKey })\n break\n }\n case 'bedrock': {\n const accessKeyId = process.env.AWS_ACCESS_KEY_ID\n const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY\n if (!accessKeyId || !secretAccessKey) {\n throw new Error('[vector.embedding] Missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY environment variables')\n }\n client = createAmazonBedrock({\n accessKeyId,\n secretAccessKey,\n region: process.env.AWS_REGION ?? 'us-east-1',\n })\n break\n }\n case 'ollama': {\n const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n try {\n assertSafeOllamaBaseUrl(baseURL)\n } catch (err) {\n if (err instanceof UnsafeOllamaBaseUrlError) {\n throw new Error(\n `[vector.embedding] Ollama base URL rejected (${err.reason}). Set OLLAMA_BASE_URL or OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.`,\n )\n }\n throw err\n }\n client = createOllama({ baseURL })\n break\n }\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n\n this.clientCache.set(providerId, client)\n return client\n }\n\n private getEmbeddingModel() {\n const client = this.getClient(this.config.providerId)\n const { providerId, model, outputDimensionality } = this.config\n\n switch (providerId) {\n case 'openai':\n return (client as ReturnType<typeof createOpenAI>).embedding(model)\n case 'google':\n return (client as ReturnType<typeof createGoogleGenerativeAI>).textEmbeddingModel(model)\n case 'mistral':\n return (client as ReturnType<typeof createMistral>).textEmbeddingModel(model)\n case 'cohere':\n return (client as ReturnType<typeof createCohere>).textEmbeddingModel(model)\n case 'bedrock':\n return (client as ReturnType<typeof createAmazonBedrock>).embedding(model)\n case 'ollama':\n return (client as OllamaClient).embedding(model)\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n }\n\n private getProviderOptions(): ProviderOptions | undefined {\n const { providerId, outputDimensionality, model } = this.config\n\n if (!outputDimensionality) {\n if (providerId === 'cohere') {\n return { cohere: { inputType: 'search_document' } }\n }\n return undefined\n }\n\n switch (providerId) {\n case 'openai':\n if (model === 'text-embedding-3-large' || model === 'text-embedding-3-small') {\n return { openai: { dimensions: outputDimensionality } }\n }\n return undefined\n case 'google':\n return { google: { outputDimensionality } }\n case 'bedrock':\n return { bedrock: { dimensions: outputDimensionality } }\n case 'cohere':\n return { cohere: { inputType: 'search_document' } }\n default:\n return undefined\n }\n }\n\n async createEmbedding(input: string | string[]): Promise<number[]> {\n const merged = Array.isArray(input)\n ? input.map((part) => String(part ?? '')).filter((part) => part.length > 0).join('\\n\\n')\n : String(input ?? '')\n if (!merged.length) {\n throw new Error('[vector.embedding] Refusing to embed empty payload')\n }\n\n if (!this.available) {\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n throw new Error(`[vector.embedding] Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`)\n }\n\n const model = this.getEmbeddingModel() as EmbeddingModel\n const providerOptions = this.getProviderOptions()\n const timeoutMs = resolveEmbeddingTimeoutMs()\n\n const abortController = new AbortController()\n let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n try {\n const result = await Promise.race([\n embed({\n model,\n value: merged,\n abortSignal: abortController.signal,\n ...(providerOptions && { providerOptions }),\n }),\n new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(\n () => {\n // Abort the in-flight request so a dead/unreachable provider releases\n // its socket \u2014 and, in a worker, the per-job DB connection held while\n // this awaits \u2014 promptly, instead of lingering until the platform's\n // default network timeout and pinning pool capacity under a storm.\n abortController.abort()\n reject(timeoutError(this.config.providerId, timeoutMs))\n },\n timeoutMs,\n )\n }),\n ])\n const emb = Array.isArray(result.embedding)\n ? result.embedding\n : Array.from(result.embedding as ArrayLike<number>)\n return emb.map((n) => Number.isFinite(n) ? Number(n) : 0)\n } catch (err: unknown) {\n const error = err as { statusCode?: number; status?: number; response?: { status?: number; statusCode?: number; data?: { error?: { message?: string; code?: string }; message?: string } }; data?: { error?: { message?: string; code?: string } }; body?: { error?: { message?: string; code?: string } }; message?: string }\n const statusCandidate =\n error?.statusCode ?? error?.status ?? error?.response?.status ?? error?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = error?.data?.error ?? error?.body?.error ?? error?.response?.data?.error\n const apiMessage = apiError?.message ?? error?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof error?.message === 'string' ? error.message : 'Embedding request failed')\n\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = `${providerInfo.name} usage quota exceeded. Please review your plan and billing.`\n break\n case 'invalid_api_key':\n guidance = `Invalid ${providerInfo.name} API key. Update the key and retry.`\n break\n case 'account_deactivated':\n guidance = `${providerInfo.name} account is disabled. Contact support or provide a different key.`\n break\n default:\n guidance = rawMessage.startsWith('[vector.embedding] ')\n ? rawMessage.slice('[vector.embedding] '.length)\n : rawMessage.includes('https://')\n ? rawMessage\n : rawMessage.includes(providerInfo.envKeyRequired)\n ? rawMessage\n : `${rawMessage}. Check ${providerInfo.envKeyRequired}.`\n }\n const wrapped = new Error(`[vector.embedding] ${guidance}`) as Error & { status?: number; code?: string; cause?: unknown }\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n wrapped.status = normalizedStatus\n }\n }\n if (apiCode) {\n wrapped.code = apiCode\n }\n wrapped.cause = err\n throw wrapped\n } finally {\n if (timeoutHandle !== null) {\n clearTimeout(timeoutHandle)\n }\n }\n }\n}\n"],
5
+ "mappings": "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",
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.6410.1.2ad881013e",
3
+ "version": "0.6.6-develop.6412.1.e99f3c32d9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.6-develop.6410.1.2ad881013e",
131
- "@open-mercato/queue": "0.6.6-develop.6410.1.2ad881013e",
132
- "@open-mercato/shared": "0.6.6-develop.6410.1.2ad881013e"
130
+ "@open-mercato/core": "0.6.6-develop.6412.1.e99f3c32d9",
131
+ "@open-mercato/queue": "0.6.6-develop.6412.1.e99f3c32d9",
132
+ "@open-mercato/shared": "0.6.6-develop.6412.1.e99f3c32d9"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -0,0 +1,167 @@
1
+ jest.mock('pg', () => ({
2
+ Pool: jest.fn().mockImplementation(() => ({
3
+ end: jest.fn().mockResolvedValue(undefined),
4
+ query: jest.fn(),
5
+ connect: jest.fn(),
6
+ })),
7
+ }))
8
+
9
+ jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
10
+ getRedisUrlOrThrow: jest.fn().mockReturnValue('redis://localhost:6379'),
11
+ }))
12
+
13
+ jest.mock('../queue/vector-indexing', () => ({
14
+ createVectorIndexingQueue: jest.fn().mockImplementation(() => ({
15
+ name: 'vector-indexing',
16
+ strategy: 'local',
17
+ enqueue: jest.fn(),
18
+ close: jest.fn().mockResolvedValue(undefined),
19
+ })),
20
+ }))
21
+
22
+ jest.mock('../queue/fulltext-indexing', () => ({
23
+ createFulltextIndexingQueue: jest.fn().mockImplementation(() => ({
24
+ name: 'fulltext-indexing',
25
+ strategy: 'local',
26
+ enqueue: jest.fn(),
27
+ close: jest.fn().mockResolvedValue(undefined),
28
+ })),
29
+ }))
30
+
31
+ jest.mock('../vector', () => ({
32
+ EmbeddingService: jest.fn().mockImplementation(() => ({ _id: Math.random() })),
33
+ createPgVectorDriver: jest.fn().mockImplementation(() => ({ _id: Math.random() })),
34
+ createChromaDbDriver: jest.fn().mockReturnValue({}),
35
+ createQdrantDriver: jest.fn().mockReturnValue({}),
36
+ }))
37
+
38
+ jest.mock('awilix', () => ({
39
+ asValue: (v: unknown) => ({ _value: v }),
40
+ }))
41
+
42
+ jest.mock('../fulltext/drivers', () => ({
43
+ createFulltextDriver: jest.fn().mockImplementation(() => ({ _id: Math.random() })),
44
+ }))
45
+
46
+ import { register } from '../modules/search/di'
47
+ import { registerSearchModule } from '../di'
48
+ import { createFulltextDriver } from '../fulltext/drivers'
49
+
50
+ const SINGLETON_KEYS = [
51
+ '__omSearchEmbeddingService__',
52
+ '__omSearchVectorDrivers__',
53
+ '__omSearchVectorIndexQueue__',
54
+ '__omSearchFulltextIndexQueue__',
55
+ '__omSearchVectorPgPool__',
56
+ '__omSearchSingletonsShutdown__',
57
+ '__omSearchFulltextDriver__',
58
+ ] as const
59
+
60
+ function clearSingletonGlobals() {
61
+ for (const key of SINGLETON_KEYS) {
62
+ delete (globalThis as Record<string, unknown>)[key]
63
+ }
64
+ }
65
+
66
+ function makeMockContainer() {
67
+ const registered: Record<string, unknown> = {}
68
+ return {
69
+ resolve: jest.fn().mockImplementation((name: string) => {
70
+ if (name in registered) return registered[name]
71
+ throw new Error(`[mock] Not registered: ${name}`)
72
+ }),
73
+ register: jest.fn().mockImplementation((regs: Record<string, { _value: unknown }>) => {
74
+ for (const [key, reg] of Object.entries(regs)) {
75
+ registered[key] = reg._value
76
+ }
77
+ }),
78
+ _registered: registered,
79
+ }
80
+ }
81
+
82
+ describe('search module DI — vector singleton cache', () => {
83
+ const originalEnv = { ...process.env }
84
+
85
+ beforeEach(() => {
86
+ jest.clearAllMocks()
87
+ clearSingletonGlobals()
88
+ process.env = { ...originalEnv, DATABASE_URL: 'postgres://localhost/test' }
89
+ })
90
+
91
+ afterAll(() => {
92
+ process.env = originalEnv
93
+ clearSingletonGlobals()
94
+ })
95
+
96
+ it('returns the same EmbeddingService instance on consecutive registrations', () => {
97
+ const c1 = makeMockContainer()
98
+ const c2 = makeMockContainer()
99
+ register(c1 as never)
100
+ register(c2 as never)
101
+
102
+ expect(c1._registered.vectorEmbeddingService).toBe(c2._registered.vectorEmbeddingService)
103
+ })
104
+
105
+ it('returns fresh EmbeddingService instances when SEARCH_DISABLE_SINGLETON_CACHE=1', () => {
106
+ process.env.SEARCH_DISABLE_SINGLETON_CACHE = '1'
107
+ const c1 = makeMockContainer()
108
+ const c2 = makeMockContainer()
109
+ register(c1 as never)
110
+ register(c2 as never)
111
+
112
+ expect(c1._registered.vectorEmbeddingService).not.toBe(c2._registered.vectorEmbeddingService)
113
+ })
114
+
115
+ it('reuses the same vectorDrivers array when cache is enabled', () => {
116
+ const c1 = makeMockContainer()
117
+ const c2 = makeMockContainer()
118
+ register(c1 as never)
119
+ register(c2 as never)
120
+
121
+ expect(c1._registered.vectorDrivers).toBe(c2._registered.vectorDrivers)
122
+ })
123
+ })
124
+
125
+ describe('registerSearchModule — fulltext driver canMemoize guard', () => {
126
+ const originalEnv = { ...process.env }
127
+
128
+ beforeEach(() => {
129
+ jest.clearAllMocks()
130
+ clearSingletonGlobals()
131
+ process.env = { ...originalEnv }
132
+ })
133
+
134
+ afterAll(() => {
135
+ process.env = originalEnv
136
+ clearSingletonGlobals()
137
+ })
138
+
139
+ it('reuses the cached fulltext driver when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is not set', () => {
140
+ const c1 = makeMockContainer()
141
+ const c2 = makeMockContainer()
142
+ registerSearchModule(c1 as never, { skipVector: true, skipTokens: true })
143
+ registerSearchModule(c2 as never, { skipVector: true, skipTokens: true })
144
+
145
+ expect(jest.mocked(createFulltextDriver)).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('does not cache fulltext driver when SEARCH_DISABLE_SINGLETON_CACHE=1', () => {
149
+ process.env.SEARCH_DISABLE_SINGLETON_CACHE = '1'
150
+ const c1 = makeMockContainer()
151
+ const c2 = makeMockContainer()
152
+ registerSearchModule(c1 as never, { skipVector: true, skipTokens: true })
153
+ registerSearchModule(c2 as never, { skipVector: true, skipTokens: true })
154
+
155
+ expect(jest.mocked(createFulltextDriver)).toHaveBeenCalledTimes(2)
156
+ })
157
+
158
+ it('does not cache fulltext driver when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled', () => {
159
+ process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS = 'true'
160
+ const c1 = makeMockContainer()
161
+ const c2 = makeMockContainer()
162
+ registerSearchModule(c1 as never, { skipVector: true, skipTokens: true })
163
+ registerSearchModule(c2 as never, { skipVector: true, skipTokens: true })
164
+
165
+ expect(jest.mocked(createFulltextDriver)).toHaveBeenCalledTimes(2)
166
+ })
167
+ })
@@ -118,3 +118,46 @@ describe('EmbeddingService', () => {
118
118
  await expect(service.createEmbedding('test input')).resolves.toEqual([0.1])
119
119
  })
120
120
  })
121
+
122
+ describe('EmbeddingService.updateConfig', () => {
123
+ const baseConfig = {
124
+ providerId: 'ollama' as const,
125
+ model: 'nomic-embed-text',
126
+ dimension: 768,
127
+ updatedAt: '2024-01-01T00:00:00.000Z',
128
+ baseUrl: 'http://localhost:11434',
129
+ }
130
+
131
+ it('is a no-op when all fields match (clientCache is not cleared)', () => {
132
+ const service = new EmbeddingService({ config: { ...baseConfig } })
133
+ const configBefore = service.currentConfig
134
+ service.updateConfig({ ...baseConfig, updatedAt: '2099-01-01T00:00:00.000Z' })
135
+ expect(service.currentConfig).toEqual(configBefore)
136
+ })
137
+
138
+ it('updates config and clears cache when model changes', () => {
139
+ const service = new EmbeddingService({ config: { ...baseConfig } })
140
+ service.updateConfig({ ...baseConfig, model: 'mxbai-embed-large' })
141
+ expect(service.currentConfig.model).toBe('mxbai-embed-large')
142
+ })
143
+
144
+ it('updates config and clears cache when baseUrl changes', () => {
145
+ const service = new EmbeddingService({ config: { ...baseConfig } })
146
+ service.updateConfig({ ...baseConfig, baseUrl: 'http://my-ollama:11434' })
147
+ expect(service.currentConfig.baseUrl).toBe('http://my-ollama:11434')
148
+ })
149
+
150
+ it('does not compare updatedAt — same config with different updatedAt is a no-op', () => {
151
+ const service = new EmbeddingService({ config: { ...baseConfig } })
152
+ const dimensionBefore = service.dimension
153
+ service.updateConfig({ ...baseConfig, updatedAt: '2099-06-17T12:00:00.000Z' })
154
+ expect(service.dimension).toBe(dimensionBefore)
155
+ expect(service.currentConfig.updatedAt).toBe(baseConfig.updatedAt)
156
+ })
157
+
158
+ it('updates config when dimension changes', () => {
159
+ const service = new EmbeddingService({ config: { ...baseConfig } })
160
+ service.updateConfig({ ...baseConfig, dimension: 1024 })
161
+ expect(service.dimension).toBe(1024)
162
+ })
163
+ })
package/src/di.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { asValue } from 'awilix'
2
2
  import type { Kysely } from 'kysely'
3
+ import type { FullTextSearchDriver } from './fulltext/types'
3
4
  import { SearchService } from './service'
4
5
  import { TokenSearchStrategy } from './strategies/token.strategy'
5
6
  import { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'
@@ -25,6 +26,16 @@ import type { EncryptionMapEntry } from './lib/field-policy'
25
26
  import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
26
27
  import { createPresenterEnricher } from './lib/presenter-enricher'
27
28
 
29
+ const FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'
30
+
31
+ type SearchModuleGlobals = {
32
+ [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver
33
+ }
34
+
35
+ function getSearchModuleGlobals(): SearchModuleGlobals {
36
+ return globalThis as unknown as SearchModuleGlobals
37
+ }
38
+
28
39
  /**
29
40
  * Check if encrypted fields should be excluded from search indexing.
30
41
  * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.
@@ -160,25 +171,44 @@ export function registerSearchModule(
160
171
 
161
172
  // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)
162
173
  if (!options?.skipFulltext) {
163
- // Build encryption map resolver if SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled
164
- let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined
165
- if (shouldExcludeEncryptedFields()) {
166
- try {
167
- const em = container.resolve<any>('em')
168
- const db = em.getKysely() as Kysely<any>
169
- encryptionMapResolver = createEncryptionMapResolver(db)
170
- } catch {
171
- // Kysely not available, encrypted field filtering disabled
174
+ const excludeEncrypted = shouldExcludeEncryptedFields()
175
+ const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'
176
+ const g = getSearchModuleGlobals()
177
+
178
+ // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is
179
+ // OFF in that case the driver holds no request-scoped Kysely reference. When the flag
180
+ // is ON the encryptionMapResolver captures a per-request db handle and must stay
181
+ // per-request to avoid cross-request data leaks.
182
+ const canMemoize = singletonCacheEnabled && !excludeEncrypted
183
+
184
+ let fulltextDriver: FullTextSearchDriver | null = canMemoize
185
+ ? (g[FULLTEXT_DRIVER_KEY] ?? null)
186
+ : null
187
+
188
+ if (!fulltextDriver) {
189
+ let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined
190
+ if (excludeEncrypted) {
191
+ try {
192
+ const em = container.resolve<any>('em')
193
+ const db = em.getKysely() as Kysely<any>
194
+ encryptionMapResolver = createEncryptionMapResolver(db)
195
+ } catch {
196
+ // Kysely not available, encrypted field filtering disabled
197
+ }
172
198
  }
173
- }
174
199
 
175
- const fulltextDriver = createFulltextDriver({
176
- fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {
177
- const config = entityConfigMap.get(entityId)
178
- return config?.fieldPolicy
179
- },
180
- encryptionMapResolver,
181
- })
200
+ fulltextDriver = createFulltextDriver({
201
+ fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {
202
+ const config = entityConfigMap.get(entityId)
203
+ return config?.fieldPolicy
204
+ },
205
+ encryptionMapResolver,
206
+ })
207
+
208
+ if (canMemoize && fulltextDriver) {
209
+ g[FULLTEXT_DRIVER_KEY] = fulltextDriver
210
+ }
211
+ }
182
212
 
183
213
  if (fulltextDriver) {
184
214
  strategies.push(new FullTextSearchStrategy(fulltextDriver))
@@ -1,37 +1,91 @@
1
1
  import { asValue } from 'awilix'
2
+ import { Pool } from 'pg'
2
3
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
4
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
4
5
  import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'
6
+ import type { PgPool } from '../../vector/drivers/pgvector'
5
7
  import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'
6
8
  import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'
7
9
  import { createEmbeddingProviderProbe } from './lib/provider-probe'
8
10
  import type { Queue } from '@open-mercato/queue'
9
11
 
10
- /**
11
- * Register search module dependencies.
12
- *
13
- * This registers:
14
- * - vectorEmbeddingService: EmbeddingService for creating embeddings
15
- * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)
16
- * - vectorIndexQueue: Queue for vector indexing jobs
17
- * - fulltextIndexQueue: Queue for fulltext indexing jobs
18
- *
19
- * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,
20
- * which is registered in the main search module DI (packages/search/src/di.ts).
21
- */
22
- export function register(container: AppContainer) {
12
+ const EMBEDDING_SERVICE_KEY = '__omSearchEmbeddingService__'
13
+ const VECTOR_DRIVERS_KEY = '__omSearchVectorDrivers__'
14
+ const VECTOR_INDEX_QUEUE_KEY = '__omSearchVectorIndexQueue__'
15
+ const FULLTEXT_INDEX_QUEUE_KEY = '__omSearchFulltextIndexQueue__'
16
+ const PG_POOL_KEY = '__omSearchVectorPgPool__'
17
+ const SHUTDOWN_KEY = '__omSearchSingletonsShutdown__'
18
+
19
+ type SearchSingletonCache = {
20
+ [EMBEDDING_SERVICE_KEY]?: EmbeddingService
21
+ [VECTOR_DRIVERS_KEY]?: ReturnType<typeof createPgVectorDriver>[]
22
+ [VECTOR_INDEX_QUEUE_KEY]?: Queue<VectorIndexJobPayload>
23
+ [FULLTEXT_INDEX_QUEUE_KEY]?: Queue<FulltextIndexJobPayload>
24
+ [PG_POOL_KEY]?: PgPool
25
+ [SHUTDOWN_KEY]?: boolean
26
+ }
27
+
28
+ function getSearchGlobals(): SearchSingletonCache {
29
+ // L1: these globals are NOT cleared on HMR — module re-evaluation hits the
30
+ // cache-hit fast-path so no new connections are opened. Restart the dev server
31
+ // (or set SEARCH_DISABLE_SINGLETON_CACHE=1) when a clean slate is needed.
32
+ return globalThis as unknown as SearchSingletonCache
33
+ }
34
+
35
+ function isSingletonCacheEnabled(): boolean {
36
+ return process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'
37
+ }
38
+
39
+ function buildQueueOptions(): {
40
+ queueStrategy: 'local' | 'async'
41
+ queueConnection: { connection: { url: string } } | undefined
42
+ } {
43
+ const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
44
+ const queueConnection = queueStrategy === 'async'
45
+ ? { connection: { url: getRedisUrlOrThrow('QUEUE') } }
46
+ : undefined
47
+ return { queueStrategy, queueConnection }
48
+ }
49
+
50
+ function getOrCreateSingletons(): {
51
+ embeddingService: EmbeddingService
52
+ drivers: ReturnType<typeof createPgVectorDriver>[]
53
+ vectorIndexQueue: Queue<VectorIndexJobPayload>
54
+ fulltextIndexQueue: Queue<FulltextIndexJobPayload>
55
+ } {
56
+ const g = getSearchGlobals()
57
+
58
+ if (
59
+ g[EMBEDDING_SERVICE_KEY] &&
60
+ g[VECTOR_DRIVERS_KEY] &&
61
+ g[VECTOR_INDEX_QUEUE_KEY] &&
62
+ g[FULLTEXT_INDEX_QUEUE_KEY]
63
+ ) {
64
+ return {
65
+ embeddingService: g[EMBEDDING_SERVICE_KEY],
66
+ drivers: g[VECTOR_DRIVERS_KEY],
67
+ vectorIndexQueue: g[VECTOR_INDEX_QUEUE_KEY],
68
+ fulltextIndexQueue: g[FULLTEXT_INDEX_QUEUE_KEY],
69
+ }
70
+ }
71
+
23
72
  const embeddingService = new EmbeddingService()
73
+
74
+ // Create the pgvector pool separately so we can close it in the shutdown hook
75
+ let pgPool: PgPool | undefined
76
+ const dbUrl = process.env.DATABASE_URL
77
+ if (dbUrl) {
78
+ pgPool = new Pool({ connectionString: dbUrl }) as unknown as PgPool
79
+ g[PG_POOL_KEY] = pgPool
80
+ }
81
+
24
82
  const drivers = [
25
- createPgVectorDriver(),
83
+ createPgVectorDriver(pgPool ? { pool: pgPool } : {}),
26
84
  createChromaDbDriver(),
27
85
  createQdrantDriver(),
28
86
  ]
29
87
 
30
- // Create queues based on environment strategy
31
- const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
32
- const queueConnection = queueStrategy === 'async'
33
- ? { connection: { url: getRedisUrlOrThrow('QUEUE') } }
34
- : undefined
88
+ const { queueStrategy, queueConnection } = buildQueueOptions()
35
89
 
36
90
  const vectorIndexQueue: Queue<VectorIndexJobPayload> = createVectorIndexingQueue(
37
91
  queueStrategy,
@@ -43,6 +97,62 @@ export function register(container: AppContainer) {
43
97
  queueConnection,
44
98
  )
45
99
 
100
+ g[EMBEDDING_SERVICE_KEY] = embeddingService
101
+ g[VECTOR_DRIVERS_KEY] = drivers
102
+ g[VECTOR_INDEX_QUEUE_KEY] = vectorIndexQueue
103
+ g[FULLTEXT_INDEX_QUEUE_KEY] = fulltextIndexQueue
104
+
105
+ if (!g[SHUTDOWN_KEY]) {
106
+ const shutdown = () => {
107
+ // L2: chromadb/qdrant drivers use stateless HTTP clients — no persistent
108
+ // connection to close. Only the pg.Pool and BullMQ queues need teardown.
109
+ g[PG_POOL_KEY]?.end().catch(() => {})
110
+ g[VECTOR_INDEX_QUEUE_KEY]?.close().catch(() => {})
111
+ g[FULLTEXT_INDEX_QUEUE_KEY]?.close().catch(() => {})
112
+ }
113
+ process.once('SIGTERM', shutdown)
114
+ process.once('SIGINT', shutdown)
115
+ g[SHUTDOWN_KEY] = true
116
+ }
117
+
118
+ return { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue }
119
+ }
120
+
121
+ /**
122
+ * Register search module dependencies.
123
+ *
124
+ * This registers:
125
+ * - vectorEmbeddingService: EmbeddingService for creating embeddings
126
+ * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant)
127
+ * - vectorIndexQueue: Queue for vector indexing jobs
128
+ * - fulltextIndexQueue: Queue for fulltext indexing jobs
129
+ *
130
+ * These four are process-scoped singletons (globalThis) to avoid per-request pg.Pool
131
+ * creation, DDL re-runs, and BullMQ/ioredis connection leaks. Disable with
132
+ * SEARCH_DISABLE_SINGLETON_CACHE=1.
133
+ *
134
+ * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead,
135
+ * which is registered in the main search module DI (packages/search/src/di.ts).
136
+ */
137
+ export function register(container: AppContainer) {
138
+ if (!isSingletonCacheEnabled()) {
139
+ const { queueStrategy, queueConnection } = buildQueueOptions()
140
+
141
+ container.register({
142
+ vectorEmbeddingService: asValue(new EmbeddingService()),
143
+ vectorDrivers: asValue([
144
+ createPgVectorDriver(),
145
+ createChromaDbDriver(),
146
+ createQdrantDriver(),
147
+ ]),
148
+ vectorIndexQueue: asValue(createVectorIndexingQueue(queueStrategy, queueConnection)),
149
+ fulltextIndexQueue: asValue(createFulltextIndexingQueue(queueStrategy, queueConnection)),
150
+ })
151
+ return
152
+ }
153
+
154
+ const { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue } = getOrCreateSingletons()
155
+
46
156
  container.register({
47
157
  vectorEmbeddingService: asValue(embeddingService),
48
158
  vectorDrivers: asValue(drivers),
@@ -6,7 +6,7 @@ type PgPoolClient = {
6
6
  query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>
7
7
  release(): void
8
8
  }
9
- type PgPool = {
9
+ export type PgPool = {
10
10
  connect(): Promise<PgPoolClient>
11
11
  query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>
12
12
  end(): Promise<void>
@@ -68,6 +68,15 @@ export class EmbeddingService {
68
68
  }
69
69
 
70
70
  updateConfig(config: EmbeddingProviderConfig): void {
71
+ if (
72
+ config.providerId === this.config.providerId &&
73
+ config.model === this.config.model &&
74
+ config.dimension === this.config.dimension &&
75
+ config.outputDimensionality === this.config.outputDimensionality &&
76
+ config.baseUrl === this.config.baseUrl
77
+ ) {
78
+ return
79
+ }
71
80
  this.config = config
72
81
  this.clientCache.clear()
73
82
  }