@open-mercato/search 0.6.6-develop.5586.1.c9ed1d68a8 → 0.6.6-develop.5588.1.a8f6c51d1f

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.
@@ -1,2 +1,2 @@
1
- [build:search] found 88 entry points
1
+ [build:search] found 89 entry points
2
2
  [build:search] built successfully
package/dist/lib/debug.js CHANGED
@@ -18,6 +18,13 @@ function searchDebugWarn(prefix, message, data) {
18
18
  console.warn(`[${prefix}] ${message}`);
19
19
  }
20
20
  }
21
+ function searchWarn(prefix, message, data) {
22
+ if (data) {
23
+ console.warn(`[${prefix}] ${message}`, data);
24
+ } else {
25
+ console.warn(`[${prefix}] ${message}`);
26
+ }
27
+ }
21
28
  function searchError(prefix, message, data) {
22
29
  if (data) {
23
30
  console.error(`[${prefix}] ${message}`, data);
@@ -29,6 +36,7 @@ export {
29
36
  isSearchDebugEnabled,
30
37
  searchDebug,
31
38
  searchDebugWarn,
32
- searchError
39
+ searchError,
40
+ searchWarn
33
41
  };
34
42
  //# sourceMappingURL=debug.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/debug.ts"],
4
- "sourcesContent": ["/**\n * Debug utilities for search module.\n *\n * Set OM_SEARCH_DEBUG=true to enable debug logging.\n */\n\nexport function isSearchDebugEnabled(): boolean {\n const raw = (process.env.OM_SEARCH_DEBUG ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Log a debug message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebug(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.log(`[${prefix}] ${message}`, data)\n } else {\n console.log(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log a warning message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebugWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.warn(`[${prefix}] ${message}`, data)\n } else {\n console.warn(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log an error message (always logs, not gated by debug flag).\n * Errors should always be visible for troubleshooting.\n */\nexport function searchError(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (data) {\n console.error(`[${prefix}] ${message}`, data)\n } else {\n console.error(`[${prefix}] ${message}`)\n }\n}\n"],
5
- "mappings": "AAMO,SAAS,uBAAgC;AAC9C,QAAM,OAAO,QAAQ,IAAI,mBAAmB,IAAI,YAAY;AAC5D,SAAO,QAAQ,OAAO,QAAQ,UAAU,QAAQ,SAAS,QAAQ;AACnE;AAKO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC5C,OAAO;AACL,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACtC;AACF;AAKO,SAAS,gBAAgB,QAAgB,SAAiB,MAAsC;AACrG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC7C,OAAO;AACL,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACvC;AACF;AAMO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,MAAM;AACR,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC9C,OAAO;AACL,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACxC;AACF;",
4
+ "sourcesContent": ["/**\n * Debug utilities for search module.\n *\n * Set OM_SEARCH_DEBUG=true to enable debug logging.\n */\n\nexport function isSearchDebugEnabled(): boolean {\n const raw = (process.env.OM_SEARCH_DEBUG ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Log a debug message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebug(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.log(`[${prefix}] ${message}`, data)\n } else {\n console.log(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log a warning message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebugWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.warn(`[${prefix}] ${message}`, data)\n } else {\n console.warn(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log a warning message (always logs, not gated by debug flag).\n * Use for operational warnings that must stay visible without OM_SEARCH_DEBUG,\n * such as skipping a vector-index run because the provider is unreachable or\n * the configured embedding dimension no longer matches the vector table.\n */\nexport function searchWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (data) {\n console.warn(`[${prefix}] ${message}`, data)\n } else {\n console.warn(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log an error message (always logs, not gated by debug flag).\n * Errors should always be visible for troubleshooting.\n */\nexport function searchError(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (data) {\n console.error(`[${prefix}] ${message}`, data)\n } else {\n console.error(`[${prefix}] ${message}`)\n }\n}\n"],
5
+ "mappings": "AAMO,SAAS,uBAAgC;AAC9C,QAAM,OAAO,QAAQ,IAAI,mBAAmB,IAAI,YAAY;AAC5D,SAAO,QAAQ,OAAO,QAAQ,UAAU,QAAQ,SAAS,QAAQ;AACnE;AAKO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC5C,OAAO;AACL,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACtC;AACF;AAKO,SAAS,gBAAgB,QAAgB,SAAiB,MAAsC;AACrG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC7C,OAAO;AACL,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACvC;AACF;AAQO,SAAS,WAAW,QAAgB,SAAiB,MAAsC;AAChG,MAAI,MAAM;AACR,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC7C,OAAO;AACL,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACvC;AACF;AAMO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,MAAM;AACR,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC9C,OAAO;AACL,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACxC;AACF;",
6
6
  "names": []
7
7
  }
@@ -4,7 +4,8 @@ import { applyCoverageAdjustments, createCoverageAdjustments } from "@open-merca
4
4
  import { logVectorOperation } from "../../../vector/lib/vector-logs.js";
5
5
  import { resolveAutoIndexingEnabled } from "../lib/auto-indexing.js";
6
6
  import { resolveEmbeddingConfig } from "../lib/embedding-config.js";
7
- import { searchDebugWarn } from "../../../lib/debug.js";
7
+ import { searchDebugWarn, searchWarn } from "../../../lib/debug.js";
8
+ import { evaluateVectorPreflight } from "../../../vector/lib/preflight.js";
8
9
  import { clearReindexLock, updateReindexProgress } from "../lib/reindex-lock.js";
9
10
  import { incrementReindexProgress } from "../lib/reindex-progress.js";
10
11
  const DEFAULT_CONCURRENCY = 2;
@@ -13,6 +14,32 @@ const metadata = {
13
14
  queue: VECTOR_INDEXING_QUEUE_NAME,
14
15
  concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
15
16
  };
17
+ async function runVectorPreflight(ctx, options) {
18
+ let embeddingService = null;
19
+ try {
20
+ embeddingService = ctx.resolve("vectorEmbeddingService");
21
+ } catch {
22
+ embeddingService = null;
23
+ }
24
+ if (!embeddingService) return { ok: true };
25
+ let tableDimension = null;
26
+ try {
27
+ const drivers = ctx.resolve("vectorDrivers");
28
+ const pgvectorDriver = drivers.find((driver) => driver.id === "pgvector");
29
+ if (pgvectorDriver?.getTableDimension) {
30
+ tableDimension = await pgvectorDriver.getTableDimension();
31
+ }
32
+ } catch {
33
+ tableDimension = null;
34
+ }
35
+ const service = embeddingService;
36
+ return evaluateVectorPreflight({
37
+ providerConfigured: service.available,
38
+ effectiveDimension: typeof service.dimension === "number" ? service.dimension : null,
39
+ tableDimension,
40
+ probe: options.withProbe ? () => service.createEmbedding("preflight") : void 0
41
+ });
42
+ }
16
43
  async function handleVectorIndexJob(job, jobCtx, ctx) {
17
44
  const { jobType, entityType, recordId, tenantId, organizationId, records } = job.payload;
18
45
  if (jobType === "batch-index") {
@@ -57,6 +84,32 @@ async function handleVectorIndexJob(job, jobCtx, ctx) {
57
84
  error: configErr instanceof Error ? configErr.message : configErr
58
85
  });
59
86
  }
87
+ const preflight = await runVectorPreflight(ctx, { withProbe: true });
88
+ if (!preflight.ok) {
89
+ searchWarn("vector-index.worker", `Skipping vector batch: ${preflight.reason}`, {
90
+ jobId: jobCtx.jobId,
91
+ code: preflight.code,
92
+ totalRecords: records.length,
93
+ tenantId
94
+ });
95
+ if (db && records.length > 0) {
96
+ await updateReindexProgress(db, tenantId, "vector", records.length, organizationId ?? null);
97
+ }
98
+ if (progressService && em2 && records.length > 0) {
99
+ const completed = await incrementReindexProgress({
100
+ em: em2,
101
+ progressService,
102
+ type: "vector",
103
+ tenantId,
104
+ organizationId: organizationId ?? null,
105
+ delta: records.length
106
+ });
107
+ if (completed && db) {
108
+ await clearReindexLock(db, tenantId, "vector", organizationId ?? null);
109
+ }
110
+ }
111
+ return;
112
+ }
60
113
  let successCount = 0;
61
114
  let failCount = 0;
62
115
  for (const { entityId, recordId: recId } of records) {
@@ -136,6 +189,19 @@ async function handleVectorIndexJob(job, jobCtx, ctx) {
136
189
  });
137
190
  }
138
191
  }
192
+ if (jobType === "index") {
193
+ const preflight = await runVectorPreflight(ctx, { withProbe: false });
194
+ if (!preflight.ok) {
195
+ searchWarn("vector-index.worker", `Skipping vector index for record: ${preflight.reason}`, {
196
+ jobId: jobCtx.jobId,
197
+ code: preflight.code,
198
+ entityType,
199
+ recordId,
200
+ tenantId
201
+ });
202
+ return;
203
+ }
204
+ }
139
205
  let em = null;
140
206
  try {
141
207
  em = ctx.resolve("em");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/workers/vector-index.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { Kysely } from 'kysely'\nimport { VECTOR_INDEXING_QUEUE_NAME, type VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport type { SearchIndexer } from '../../../indexer/search-indexer'\nimport type { EmbeddingService } from '../../../vector'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { applyCoverageAdjustments, createCoverageAdjustments } from '@open-mercato/core/modules/query_index/lib/coverage'\nimport { logVectorOperation } from '../../../vector/lib/vector-logs'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { resolveEmbeddingConfig } from '../lib/embedding-config'\nimport { searchDebugWarn } from '../../../lib/debug'\nimport { clearReindexLock, updateReindexProgress } from '../lib/reindex-lock'\nimport { incrementReindexProgress } from '../lib/reindex-progress'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 2\nconst envConcurrency = process.env.WORKERS_VECTOR_INDEXING_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: VECTOR_INDEXING_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a vector index job.\n *\n * This handler is called by the queue worker to process indexing and deletion jobs.\n * It uses SearchIndexer to load records and index them via SearchService.\n *\n * @param job - The queued job containing payload\n * @param jobCtx - Queue job context with job ID and attempt info\n * @param ctx - DI container context for resolving services\n */\nexport async function handleVectorIndexJob(\n job: QueuedJob<VectorIndexJobPayload>,\n jobCtx: JobContext,\n ctx: HandlerContext,\n): Promise<void> {\n const { jobType, entityType, recordId, tenantId, organizationId, records } = job.payload\n\n // Handle batch-index jobs (from reindex operations)\n if (jobType === 'batch-index') {\n if (!records?.length || !tenantId) {\n searchDebugWarn('vector-index.worker', 'Skipping batch-index job with missing required fields', {\n jobId: jobCtx.jobId,\n recordCount: records?.length ?? 0,\n tenantId,\n })\n return\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('vector-index.worker', 'searchIndexer not available')\n return\n }\n\n // Get Kysely for heartbeat updates\n let db: Kysely<any> | null = null\n let em: EntityManager | null = null\n try {\n em = ctx.resolve('em') as EntityManager\n db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\n } catch {\n db = null\n em = null\n }\n\n let progressService: ProgressService | null = null\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n } catch {\n progressService = null\n }\n\n // Load saved embedding config to use the correct provider/model\n try {\n const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch (configErr) {\n searchDebugWarn('vector-index.worker', 'Failed to load embedding config for batch, using defaults', {\n error: configErr instanceof Error ? configErr.message : configErr,\n })\n }\n\n // Process each record in the batch\n let successCount = 0\n let failCount = 0\n for (const { entityId, recordId: recId } of records) {\n try {\n const result = await searchIndexer.indexRecordById({\n entityId: entityId as Parameters<typeof searchIndexer.indexRecordById>[0]['entityId'],\n recordId: recId,\n tenantId,\n organizationId,\n })\n if (result.action === 'indexed') {\n successCount++\n }\n } catch (error) {\n failCount++\n searchDebugWarn('vector-index.worker', 'Failed to index record in batch', {\n entityId,\n recordId: recId,\n error: error instanceof Error ? error.message : error,\n })\n }\n }\n\n // Update heartbeat to signal worker is still processing\n if (db && records.length > 0) {\n await updateReindexProgress(db, tenantId, 'vector', successCount, organizationId ?? null)\n }\n if (progressService && em && records.length > 0) {\n const completed = await incrementReindexProgress({\n em,\n progressService,\n type: 'vector',\n tenantId,\n organizationId: organizationId ?? null,\n delta: successCount,\n })\n if (completed && db) {\n await clearReindexLock(db, tenantId, 'vector', organizationId ?? null)\n }\n }\n\n searchDebugWarn('vector-index.worker', 'Batch-index job completed', {\n jobId: jobCtx.jobId,\n totalRecords: records.length,\n successCount,\n failCount,\n })\n return\n }\n\n // Handle single record jobs (index/delete)\n if (!entityType || !recordId || !tenantId) {\n searchDebugWarn('vector-index.worker', 'Skipping job with missing required fields', {\n jobId: jobCtx.jobId,\n entityType,\n recordId,\n tenantId,\n })\n return\n }\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) {\n return\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('vector-index.worker', 'searchIndexer not available')\n return\n }\n\n // Load saved embedding config to use the correct provider/model\n try {\n const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch (configErr) {\n // Delete operations don't require embedding, only warn for index operations\n if (jobType === 'index') {\n searchDebugWarn('vector-index.worker', 'Failed to load embedding config, using defaults', {\n error: configErr instanceof Error ? configErr.message : configErr,\n })\n }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n let eventBus: { emitEvent(event: string, payload: unknown, options?: unknown): Promise<void> } | null = null\n try {\n eventBus = ctx.resolve('eventBus')\n } catch {\n eventBus = null\n }\n\n const handlerName = jobType === 'delete'\n ? 'worker:vector-indexing:delete'\n : 'worker:vector-indexing:index'\n\n try {\n let action: 'indexed' | 'deleted' | 'skipped' = 'skipped'\n let delta = 0\n\n if (jobType === 'delete') {\n await searchIndexer.deleteRecord({\n entityId: entityType,\n recordId,\n tenantId,\n })\n action = 'deleted'\n delta = -1\n } else {\n const result = await searchIndexer.indexRecordById({\n entityId: entityType,\n recordId,\n tenantId,\n organizationId,\n })\n action = result.action\n if (result.action === 'indexed') {\n delta = 1\n }\n }\n\n if (delta !== 0) {\n let adjustmentsApplied = false\n if (em) {\n try {\n const adjustments = createCoverageAdjustments({\n entityType,\n tenantId,\n organizationId,\n baseDelta: 0,\n indexDelta: 0,\n vectorDelta: delta,\n })\n if (adjustments.length) {\n await applyCoverageAdjustments(em, adjustments)\n adjustmentsApplied = true\n }\n } catch (coverageError) {\n searchDebugWarn('vector-index.worker', 'Failed to adjust vector coverage', {\n error: coverageError instanceof Error ? coverageError.message : coverageError,\n })\n }\n }\n\n if (!adjustmentsApplied && eventBus) {\n try {\n await eventBus.emitEvent('query_index.coverage.refresh', {\n entityType,\n tenantId,\n organizationId,\n withDeleted: false,\n delayMs: 1000,\n })\n } catch (emitError) {\n searchDebugWarn('vector-index.worker', 'Failed to enqueue coverage refresh', {\n error: emitError instanceof Error ? emitError.message : emitError,\n })\n }\n }\n }\n\n await logVectorOperation({\n em,\n handler: handlerName,\n entityType,\n recordId,\n result: {\n action,\n tenantId,\n organizationId: organizationId ?? null,\n created: action === 'indexed',\n existed: action === 'deleted',\n },\n })\n } catch (error) {\n searchDebugWarn('vector-index.worker', `Failed to ${jobType} vector index`, {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n await recordIndexerError(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: handlerName,\n error,\n entityType,\n recordId,\n tenantId,\n organizationId,\n payload: job.payload,\n },\n )\n // Re-throw to let the queue handle retry logic\n throw error\n }\n}\n\n/**\n * Default export for worker auto-discovery.\n * Wraps handleVectorIndexJob to match the expected handler signature.\n */\nexport default async function handle(\n job: QueuedJob<VectorIndexJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n return handleVectorIndexJob(job, ctx, ctx)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { Kysely } from 'kysely'\nimport { VECTOR_INDEXING_QUEUE_NAME, type VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport type { SearchIndexer } from '../../../indexer/search-indexer'\nimport type { EmbeddingService, VectorDriver } from '../../../vector'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { applyCoverageAdjustments, createCoverageAdjustments } from '@open-mercato/core/modules/query_index/lib/coverage'\nimport { logVectorOperation } from '../../../vector/lib/vector-logs'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { resolveEmbeddingConfig } from '../lib/embedding-config'\nimport { searchDebugWarn, searchWarn } from '../../../lib/debug'\nimport { evaluateVectorPreflight, type VectorPreflightResult } from '../../../vector/lib/preflight'\nimport { clearReindexLock, updateReindexProgress } from '../lib/reindex-lock'\nimport { incrementReindexProgress } from '../lib/reindex-progress'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 2\nconst envConcurrency = process.env.WORKERS_VECTOR_INDEXING_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: VECTOR_INDEXING_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Decide once per job whether vector work can succeed, so the worker can skip a\n * doomed run with a single warning instead of failing every record. When the\n * embedding service is not resolvable we return `ok` and let the existing\n * strategy path decide, preserving prior behavior.\n *\n * `withProbe` issues one tiny embedding to detect an unreachable provider; use\n * it for bulk reindex batches, not for hot single-record writes.\n */\nasync function runVectorPreflight(\n ctx: HandlerContext,\n options: { withProbe: boolean },\n): Promise<VectorPreflightResult> {\n let embeddingService: EmbeddingService | null = null\n try {\n embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')\n } catch {\n embeddingService = null\n }\n if (!embeddingService) return { ok: true }\n\n let tableDimension: number | null = null\n try {\n const drivers = ctx.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((driver) => driver.id === 'pgvector')\n if (pgvectorDriver?.getTableDimension) {\n tableDimension = await pgvectorDriver.getTableDimension()\n }\n } catch {\n tableDimension = null\n }\n\n const service = embeddingService\n return evaluateVectorPreflight({\n providerConfigured: service.available,\n effectiveDimension: typeof service.dimension === 'number' ? service.dimension : null,\n tableDimension,\n probe: options.withProbe ? () => service.createEmbedding('preflight') : undefined,\n })\n}\n\n/**\n * Process a vector index job.\n *\n * This handler is called by the queue worker to process indexing and deletion jobs.\n * It uses SearchIndexer to load records and index them via SearchService.\n *\n * @param job - The queued job containing payload\n * @param jobCtx - Queue job context with job ID and attempt info\n * @param ctx - DI container context for resolving services\n */\nexport async function handleVectorIndexJob(\n job: QueuedJob<VectorIndexJobPayload>,\n jobCtx: JobContext,\n ctx: HandlerContext,\n): Promise<void> {\n const { jobType, entityType, recordId, tenantId, organizationId, records } = job.payload\n\n // Handle batch-index jobs (from reindex operations)\n if (jobType === 'batch-index') {\n if (!records?.length || !tenantId) {\n searchDebugWarn('vector-index.worker', 'Skipping batch-index job with missing required fields', {\n jobId: jobCtx.jobId,\n recordCount: records?.length ?? 0,\n tenantId,\n })\n return\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('vector-index.worker', 'searchIndexer not available')\n return\n }\n\n // Get Kysely for heartbeat updates\n let db: Kysely<any> | null = null\n let em: EntityManager | null = null\n try {\n em = ctx.resolve('em') as EntityManager\n db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\n } catch {\n db = null\n em = null\n }\n\n let progressService: ProgressService | null = null\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n } catch {\n progressService = null\n }\n\n // Load saved embedding config to use the correct provider/model\n try {\n const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch (configErr) {\n searchDebugWarn('vector-index.worker', 'Failed to load embedding config for batch, using defaults', {\n error: configErr instanceof Error ? configErr.message : configErr,\n })\n }\n\n // Preflight once: if the provider is unreachable/misconfigured or its\n // dimension no longer matches the shared vector table, skip the whole batch\n // with a single warning instead of failing every record. Still advance the\n // reindex progress/lock so the run completes (records counted as processed).\n const preflight = await runVectorPreflight(ctx, { withProbe: true })\n if (!preflight.ok) {\n searchWarn('vector-index.worker', `Skipping vector batch: ${preflight.reason}`, {\n jobId: jobCtx.jobId,\n code: preflight.code,\n totalRecords: records.length,\n tenantId,\n })\n if (db && records.length > 0) {\n await updateReindexProgress(db, tenantId, 'vector', records.length, organizationId ?? null)\n }\n if (progressService && em && records.length > 0) {\n const completed = await incrementReindexProgress({\n em,\n progressService,\n type: 'vector',\n tenantId,\n organizationId: organizationId ?? null,\n delta: records.length,\n })\n if (completed && db) {\n await clearReindexLock(db, tenantId, 'vector', organizationId ?? null)\n }\n }\n return\n }\n\n // Process each record in the batch\n let successCount = 0\n let failCount = 0\n for (const { entityId, recordId: recId } of records) {\n try {\n const result = await searchIndexer.indexRecordById({\n entityId: entityId as Parameters<typeof searchIndexer.indexRecordById>[0]['entityId'],\n recordId: recId,\n tenantId,\n organizationId,\n })\n if (result.action === 'indexed') {\n successCount++\n }\n } catch (error) {\n failCount++\n searchDebugWarn('vector-index.worker', 'Failed to index record in batch', {\n entityId,\n recordId: recId,\n error: error instanceof Error ? error.message : error,\n })\n }\n }\n\n // Update heartbeat to signal worker is still processing\n if (db && records.length > 0) {\n await updateReindexProgress(db, tenantId, 'vector', successCount, organizationId ?? null)\n }\n if (progressService && em && records.length > 0) {\n const completed = await incrementReindexProgress({\n em,\n progressService,\n type: 'vector',\n tenantId,\n organizationId: organizationId ?? null,\n delta: successCount,\n })\n if (completed && db) {\n await clearReindexLock(db, tenantId, 'vector', organizationId ?? null)\n }\n }\n\n searchDebugWarn('vector-index.worker', 'Batch-index job completed', {\n jobId: jobCtx.jobId,\n totalRecords: records.length,\n successCount,\n failCount,\n })\n return\n }\n\n // Handle single record jobs (index/delete)\n if (!entityType || !recordId || !tenantId) {\n searchDebugWarn('vector-index.worker', 'Skipping job with missing required fields', {\n jobId: jobCtx.jobId,\n entityType,\n recordId,\n tenantId,\n })\n return\n }\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) {\n return\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('vector-index.worker', 'searchIndexer not available')\n return\n }\n\n // Load saved embedding config to use the correct provider/model\n try {\n const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch (configErr) {\n // Delete operations don't require embedding, only warn for index operations\n if (jobType === 'index') {\n searchDebugWarn('vector-index.worker', 'Failed to load embedding config, using defaults', {\n error: configErr instanceof Error ? configErr.message : configErr,\n })\n }\n }\n\n // Preflight index jobs only (delete never needs the provider): skip with a\n // single warning when the provider is misconfigured or the configured\n // dimension no longer matches the shared vector table. No reachability probe\n // here \u2014 single-record writes are the hot path and the cheap checks already\n // catch the common misconfiguration without an extra embedding call.\n if (jobType === 'index') {\n const preflight = await runVectorPreflight(ctx, { withProbe: false })\n if (!preflight.ok) {\n searchWarn('vector-index.worker', `Skipping vector index for record: ${preflight.reason}`, {\n jobId: jobCtx.jobId,\n code: preflight.code,\n entityType,\n recordId,\n tenantId,\n })\n return\n }\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n let eventBus: { emitEvent(event: string, payload: unknown, options?: unknown): Promise<void> } | null = null\n try {\n eventBus = ctx.resolve('eventBus')\n } catch {\n eventBus = null\n }\n\n const handlerName = jobType === 'delete'\n ? 'worker:vector-indexing:delete'\n : 'worker:vector-indexing:index'\n\n try {\n let action: 'indexed' | 'deleted' | 'skipped' = 'skipped'\n let delta = 0\n\n if (jobType === 'delete') {\n await searchIndexer.deleteRecord({\n entityId: entityType,\n recordId,\n tenantId,\n })\n action = 'deleted'\n delta = -1\n } else {\n const result = await searchIndexer.indexRecordById({\n entityId: entityType,\n recordId,\n tenantId,\n organizationId,\n })\n action = result.action\n if (result.action === 'indexed') {\n delta = 1\n }\n }\n\n if (delta !== 0) {\n let adjustmentsApplied = false\n if (em) {\n try {\n const adjustments = createCoverageAdjustments({\n entityType,\n tenantId,\n organizationId,\n baseDelta: 0,\n indexDelta: 0,\n vectorDelta: delta,\n })\n if (adjustments.length) {\n await applyCoverageAdjustments(em, adjustments)\n adjustmentsApplied = true\n }\n } catch (coverageError) {\n searchDebugWarn('vector-index.worker', 'Failed to adjust vector coverage', {\n error: coverageError instanceof Error ? coverageError.message : coverageError,\n })\n }\n }\n\n if (!adjustmentsApplied && eventBus) {\n try {\n await eventBus.emitEvent('query_index.coverage.refresh', {\n entityType,\n tenantId,\n organizationId,\n withDeleted: false,\n delayMs: 1000,\n })\n } catch (emitError) {\n searchDebugWarn('vector-index.worker', 'Failed to enqueue coverage refresh', {\n error: emitError instanceof Error ? emitError.message : emitError,\n })\n }\n }\n }\n\n await logVectorOperation({\n em,\n handler: handlerName,\n entityType,\n recordId,\n result: {\n action,\n tenantId,\n organizationId: organizationId ?? null,\n created: action === 'indexed',\n existed: action === 'deleted',\n },\n })\n } catch (error) {\n searchDebugWarn('vector-index.worker', `Failed to ${jobType} vector index`, {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n await recordIndexerError(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: handlerName,\n error,\n entityType,\n recordId,\n tenantId,\n organizationId,\n payload: job.payload,\n },\n )\n // Re-throw to let the queue handle retry logic\n throw error\n }\n}\n\n/**\n * Default export for worker auto-discovery.\n * Wraps handleVectorIndexJob to match the expected handler signature.\n */\nexport default async function handle(\n job: QueuedJob<VectorIndexJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n return handleVectorIndexJob(job, ctx, ctx)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["searchIndexer", "em"]
7
7
  }
@@ -0,0 +1,32 @@
1
+ async function evaluateVectorPreflight(input) {
2
+ if (!input.providerConfigured) {
3
+ return {
4
+ ok: false,
5
+ code: "provider_not_configured",
6
+ reason: "embedding provider is not configured (missing API key/base URL); set the provider credentials or re-point the provider in Settings \u2192 Search"
7
+ };
8
+ }
9
+ if (typeof input.effectiveDimension === "number" && typeof input.tableDimension === "number" && input.effectiveDimension !== input.tableDimension) {
10
+ return {
11
+ ok: false,
12
+ code: "dimension_mismatch",
13
+ reason: `configured provider produces ${input.effectiveDimension}-dim embeddings but the shared vector table is ${input.tableDimension}-dim; re-point the provider in Settings \u2192 Search to recreate the table at the new dimension, then reindex`
14
+ };
15
+ }
16
+ if (input.probe) {
17
+ try {
18
+ await input.probe();
19
+ } catch (error) {
20
+ return {
21
+ ok: false,
22
+ code: "provider_unreachable",
23
+ reason: `embedding provider is unreachable: ${error instanceof Error ? error.message : String(error)}`
24
+ };
25
+ }
26
+ }
27
+ return { ok: true };
28
+ }
29
+ export {
30
+ evaluateVectorPreflight
31
+ };
32
+ //# sourceMappingURL=preflight.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/vector/lib/preflight.ts"],
4
+ "sourcesContent": ["/**\n * Vector indexing preflight.\n *\n * The embedding provider config and the pgvector table dimension are global\n * (per-database), not per-tenant. When the configured provider is unreachable,\n * not configured, or produces a dimension that no longer matches the shared\n * vector table, every record in a reindex run fails the same way \u2014 producing a\n * storm of per-record errors and wasted embedding calls.\n *\n * This helper lets a caller decide ONCE per run whether vector work can succeed,\n * so it can skip with a single warning instead of failing every record. It is a\n * pure function: the reachability probe is injected so it stays unit-testable.\n */\n\nexport type VectorPreflightSkipCode =\n | 'provider_not_configured'\n | 'dimension_mismatch'\n | 'provider_unreachable'\n\nexport type VectorPreflightInput = {\n /** Whether the active embedding provider has its credentials/config present. */\n providerConfigured: boolean\n /** Dimension the active embedding config will produce (null when unknown). */\n effectiveDimension: number | null\n /** Dimension of the shared vector table (null when unknown/unavailable). */\n tableDimension: number | null\n /**\n * Optional reachability probe. When provided it is invoked last and MUST\n * throw if the provider cannot be reached. Omit it on hot paths (e.g.\n * single-record indexing) where the extra embedding call is not worth it.\n */\n probe?: () => Promise<unknown>\n}\n\nexport type VectorPreflightResult =\n | { ok: true }\n | { ok: false; code: VectorPreflightSkipCode; reason: string }\n\nexport async function evaluateVectorPreflight(\n input: VectorPreflightInput,\n): Promise<VectorPreflightResult> {\n if (!input.providerConfigured) {\n return {\n ok: false,\n code: 'provider_not_configured',\n reason:\n 'embedding provider is not configured (missing API key/base URL); set the provider credentials or re-point the provider in Settings \u2192 Search',\n }\n }\n\n if (\n typeof input.effectiveDimension === 'number' &&\n typeof input.tableDimension === 'number' &&\n input.effectiveDimension !== input.tableDimension\n ) {\n return {\n ok: false,\n code: 'dimension_mismatch',\n reason:\n `configured provider produces ${input.effectiveDimension}-dim embeddings but the shared vector table is ${input.tableDimension}-dim; ` +\n 're-point the provider in Settings \u2192 Search to recreate the table at the new dimension, then reindex',\n }\n }\n\n if (input.probe) {\n try {\n await input.probe()\n } catch (error) {\n return {\n ok: false,\n code: 'provider_unreachable',\n reason: `embedding provider is unreachable: ${error instanceof Error ? error.message : String(error)}`,\n }\n }\n }\n\n return { ok: true }\n}\n"],
5
+ "mappings": "AAsCA,eAAsB,wBACpB,OACgC;AAChC,MAAI,CAAC,MAAM,oBAAoB;AAC7B,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,QACE;AAAA,IACJ;AAAA,EACF;AAEA,MACE,OAAO,MAAM,uBAAuB,YACpC,OAAO,MAAM,mBAAmB,YAChC,MAAM,uBAAuB,MAAM,gBACnC;AACA,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,QACE,gCAAgC,MAAM,kBAAkB,kDAAkD,MAAM,cAAc;AAAA,IAElI;AAAA,EACF;AAEA,MAAI,MAAM,OAAO;AACf,QAAI;AACF,YAAM,MAAM,MAAM;AAAA,IACpB,SAAS,OAAO;AACd,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,QAAQ,sCAAsC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,MACtG;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,IAAI,KAAK;AACpB;",
6
+ "names": []
7
+ }
@@ -187,17 +187,22 @@ class EmbeddingService {
187
187
  const model = this.getEmbeddingModel();
188
188
  const providerOptions = this.getProviderOptions();
189
189
  const timeoutMs = resolveEmbeddingTimeoutMs();
190
+ const abortController = new AbortController();
190
191
  let timeoutHandle = null;
191
192
  try {
192
193
  const result = await Promise.race([
193
194
  embed({
194
195
  model,
195
196
  value: merged,
197
+ abortSignal: abortController.signal,
196
198
  ...providerOptions && { providerOptions }
197
199
  }),
198
200
  new Promise((_, reject) => {
199
201
  timeoutHandle = setTimeout(
200
- () => reject(timeoutError(this.config.providerId, timeoutMs)),
202
+ () => {
203
+ abortController.abort();
204
+ reject(timeoutError(this.config.providerId, timeoutMs));
205
+ },
201
206
  timeoutMs
202
207
  );
203
208
  })
@@ -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'\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 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 let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n try {\n const result = await Promise.race([\n embed({\n model,\n value: merged,\n ...(providerOptions && { providerOptions }),\n }),\n new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(\n () => reject(timeoutError(this.config.providerId, timeoutMs)),\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'\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 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.5586.1.c9ed1d68a8",
3
+ "version": "0.6.6-develop.5588.1.a8f6c51d1f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -126,9 +126,9 @@
126
126
  "zod": "^4.4.3"
127
127
  },
128
128
  "peerDependencies": {
129
- "@open-mercato/core": "0.6.6-develop.5586.1.c9ed1d68a8",
130
- "@open-mercato/queue": "0.6.6-develop.5586.1.c9ed1d68a8",
131
- "@open-mercato/shared": "0.6.6-develop.5586.1.c9ed1d68a8"
129
+ "@open-mercato/core": "0.6.6-develop.5588.1.a8f6c51d1f",
130
+ "@open-mercato/queue": "0.6.6-develop.5588.1.a8f6c51d1f",
131
+ "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/jest": "^30.0.0",
@@ -38,6 +38,29 @@ describe('EmbeddingService', () => {
38
38
  )
39
39
  })
40
40
 
41
+ it('aborts the in-flight request when it times out', async () => {
42
+ process.env.OLLAMA_BASE_URL = 'http://localhost:11434'
43
+ process.env.VECTOR_EMBEDDING_TIMEOUT_MS = '5'
44
+ let capturedSignal: AbortSignal | undefined
45
+ mockedEmbed.mockImplementation((options) => {
46
+ capturedSignal = (options as { abortSignal?: AbortSignal }).abortSignal
47
+ return new Promise(() => undefined)
48
+ })
49
+
50
+ const service = new EmbeddingService({
51
+ config: {
52
+ providerId: 'ollama',
53
+ model: 'nomic-embed-text',
54
+ dimension: 768,
55
+ updatedAt: new Date().toISOString(),
56
+ },
57
+ })
58
+
59
+ await expect(service.createEmbedding('test input')).rejects.toThrow('timed out')
60
+ expect(capturedSignal).toBeInstanceOf(AbortSignal)
61
+ expect(capturedSignal?.aborted).toBe(true)
62
+ })
63
+
41
64
  it('returns embeddings when provider responds before the timeout', async () => {
42
65
  process.env.OLLAMA_BASE_URL = 'http://localhost:11434'
43
66
  process.env.VECTOR_EMBEDDING_TIMEOUT_MS = '100'
@@ -0,0 +1,78 @@
1
+ import { evaluateVectorPreflight } from '../vector/lib/preflight'
2
+
3
+ describe('evaluateVectorPreflight', () => {
4
+ it('passes when provider is configured, dimensions match, and probe succeeds', async () => {
5
+ const probe = jest.fn().mockResolvedValue([0.1, 0.2])
6
+ const result = await evaluateVectorPreflight({
7
+ providerConfigured: true,
8
+ effectiveDimension: 1536,
9
+ tableDimension: 1536,
10
+ probe,
11
+ })
12
+ expect(result).toEqual({ ok: true })
13
+ expect(probe).toHaveBeenCalledTimes(1)
14
+ })
15
+
16
+ it('skips when the provider is not configured (and does not probe)', async () => {
17
+ const probe = jest.fn()
18
+ const result = await evaluateVectorPreflight({
19
+ providerConfigured: false,
20
+ effectiveDimension: 1536,
21
+ tableDimension: 1536,
22
+ probe,
23
+ })
24
+ expect(result.ok).toBe(false)
25
+ if (result.ok) throw new Error('expected skip')
26
+ expect(result.code).toBe('provider_not_configured')
27
+ expect(probe).not.toHaveBeenCalled()
28
+ })
29
+
30
+ it('skips when the configured dimension differs from the table dimension', async () => {
31
+ const probe = jest.fn()
32
+ const result = await evaluateVectorPreflight({
33
+ providerConfigured: true,
34
+ effectiveDimension: 1536,
35
+ tableDimension: 768,
36
+ probe,
37
+ })
38
+ expect(result.ok).toBe(false)
39
+ if (result.ok) throw new Error('expected skip')
40
+ expect(result.code).toBe('dimension_mismatch')
41
+ expect(result.reason).toContain('1536')
42
+ expect(result.reason).toContain('768')
43
+ // Dimension mismatch is detected before the (expensive) probe runs.
44
+ expect(probe).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('skips when the reachability probe throws', async () => {
48
+ const probe = jest.fn().mockRejectedValue(new Error('fetch failed. Check OLLAMA_BASE_URL.'))
49
+ const result = await evaluateVectorPreflight({
50
+ providerConfigured: true,
51
+ effectiveDimension: 768,
52
+ tableDimension: 768,
53
+ probe,
54
+ })
55
+ expect(result.ok).toBe(false)
56
+ if (result.ok) throw new Error('expected skip')
57
+ expect(result.code).toBe('provider_unreachable')
58
+ expect(result.reason).toContain('OLLAMA_BASE_URL')
59
+ })
60
+
61
+ it('does not treat unknown dimensions as a mismatch', async () => {
62
+ const result = await evaluateVectorPreflight({
63
+ providerConfigured: true,
64
+ effectiveDimension: null,
65
+ tableDimension: 768,
66
+ })
67
+ expect(result).toEqual({ ok: true })
68
+ })
69
+
70
+ it('passes without a probe when provider is configured and dimensions match', async () => {
71
+ const result = await evaluateVectorPreflight({
72
+ providerConfigured: true,
73
+ effectiveDimension: 1536,
74
+ tableDimension: 1536,
75
+ })
76
+ expect(result).toEqual({ ok: true })
77
+ })
78
+ })
@@ -30,8 +30,19 @@ jest.mock('../modules/search/lib/embedding-config', () => ({
30
30
  resolveEmbeddingConfig: jest.fn().mockResolvedValue(null),
31
31
  }))
32
32
 
33
+ jest.mock('../modules/search/lib/reindex-lock', () => ({
34
+ updateReindexProgress: jest.fn().mockResolvedValue(undefined),
35
+ clearReindexLock: jest.fn().mockResolvedValue(undefined),
36
+ }))
37
+
38
+ jest.mock('../modules/search/lib/reindex-progress', () => ({
39
+ incrementReindexProgress: jest.fn().mockResolvedValue(true),
40
+ }))
41
+
33
42
  import { handleVectorIndexJob } from '../modules/search/workers/vector-index.worker'
34
43
  import { handleFulltextIndexJob } from '../modules/search/workers/fulltext-index.worker'
44
+ import { updateReindexProgress, clearReindexLock } from '../modules/search/lib/reindex-lock'
45
+ import { incrementReindexProgress } from '../modules/search/lib/reindex-progress'
35
46
 
36
47
  /**
37
48
  * Create a mock job context
@@ -62,18 +73,41 @@ describe('Vector Index Worker', () => {
62
73
  deleteRecord: jest.fn().mockResolvedValue({ action: 'deleted', existed: true }),
63
74
  }
64
75
 
76
+ // Configurable embedding/preflight surface — defaults to a healthy provider.
77
+ const mockEmbeddingService: {
78
+ updateConfig: jest.Mock
79
+ available: boolean
80
+ dimension: number
81
+ createEmbedding: jest.Mock
82
+ } = {
83
+ updateConfig: jest.fn(),
84
+ available: true,
85
+ dimension: 1536,
86
+ createEmbedding: jest.fn().mockResolvedValue([0.1, 0.2, 0.3]),
87
+ }
88
+ let mockTableDimension: number | null = null
89
+ const mockPgvectorDriver = {
90
+ id: 'pgvector',
91
+ getTableDimension: jest.fn(async () => mockTableDimension),
92
+ }
93
+
65
94
  const mockContainer: HandlerContext = {
66
95
  resolve: jest.fn((name: string) => {
67
96
  if (name === 'searchIndexer') return mockSearchIndexer
68
97
  if (name === 'em') return null
69
98
  if (name === 'eventBus') return null
70
- if (name === 'vectorEmbeddingService') return { updateConfig: jest.fn() }
99
+ if (name === 'vectorEmbeddingService') return mockEmbeddingService
100
+ if (name === 'vectorDrivers') return [mockPgvectorDriver]
71
101
  throw new Error(`Unknown service: ${name}`)
72
102
  }) as HandlerContext['resolve'],
73
103
  }
74
104
 
75
105
  beforeEach(() => {
76
106
  jest.clearAllMocks()
107
+ mockEmbeddingService.available = true
108
+ mockEmbeddingService.dimension = 1536
109
+ mockEmbeddingService.createEmbedding.mockResolvedValue([0.1, 0.2, 0.3])
110
+ mockTableDimension = null
77
111
  })
78
112
 
79
113
  it('should skip job with missing required fields', async () => {
@@ -148,6 +182,124 @@ describe('Vector Index Worker', () => {
148
182
  // Should not throw
149
183
  await handleVectorIndexJob(job, ctx, containerWithoutService)
150
184
  })
185
+
186
+ it('should skip a batch with one warning when the dimension mismatches (no per-record indexing)', async () => {
187
+ mockTableDimension = 768
188
+ mockEmbeddingService.dimension = 1536
189
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
190
+ const job = createMockJob<VectorIndexJobPayload>({
191
+ jobType: 'batch-index',
192
+ tenantId: 'tenant-123',
193
+ organizationId: 'org-456',
194
+ records: [
195
+ { entityId: 'test:entity', recordId: 'rec-1' },
196
+ { entityId: 'test:entity', recordId: 'rec-2' },
197
+ ],
198
+ })
199
+
200
+ await handleVectorIndexJob(job, createMockJobContext(), mockContainer)
201
+
202
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
203
+ expect(mockEmbeddingService.createEmbedding).not.toHaveBeenCalled()
204
+ expect(warnSpy).toHaveBeenCalledTimes(1)
205
+ expect(String(warnSpy.mock.calls[0][0])).toContain('Skipping vector batch')
206
+ warnSpy.mockRestore()
207
+ })
208
+
209
+ it('should skip a batch with one warning when the provider probe is unreachable', async () => {
210
+ mockTableDimension = 1536
211
+ mockEmbeddingService.dimension = 1536
212
+ mockEmbeddingService.createEmbedding.mockRejectedValueOnce(
213
+ new Error('fetch failed. Check OLLAMA_BASE_URL.'),
214
+ )
215
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
216
+ const job = createMockJob<VectorIndexJobPayload>({
217
+ jobType: 'batch-index',
218
+ tenantId: 'tenant-123',
219
+ organizationId: null,
220
+ records: [{ entityId: 'test:entity', recordId: 'rec-1' }],
221
+ })
222
+
223
+ await handleVectorIndexJob(job, createMockJobContext(), mockContainer)
224
+
225
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
226
+ expect(mockEmbeddingService.createEmbedding).toHaveBeenCalledTimes(1)
227
+ expect(warnSpy).toHaveBeenCalledTimes(1)
228
+ expect(String(warnSpy.mock.calls[0][0])).toContain('Skipping vector batch')
229
+ warnSpy.mockRestore()
230
+ })
231
+
232
+ it('should still advance reindex progress/lock when a batch is skipped (no stuck run)', async () => {
233
+ mockTableDimension = 768
234
+ mockEmbeddingService.dimension = 1536
235
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
236
+ const mockDb = { kysely: true }
237
+ const containerWithProgress: HandlerContext = {
238
+ resolve: jest.fn((name: string) => {
239
+ if (name === 'searchIndexer') return mockSearchIndexer
240
+ if (name === 'em') return { getKysely: () => mockDb }
241
+ if (name === 'progressService') return { id: 'progress' }
242
+ if (name === 'vectorEmbeddingService') return mockEmbeddingService
243
+ if (name === 'vectorDrivers') return [mockPgvectorDriver]
244
+ throw new Error(`Unknown service: ${name}`)
245
+ }) as HandlerContext['resolve'],
246
+ }
247
+ const job = createMockJob<VectorIndexJobPayload>({
248
+ jobType: 'batch-index',
249
+ tenantId: 'tenant-123',
250
+ organizationId: 'org-456',
251
+ records: [
252
+ { entityId: 'test:entity', recordId: 'rec-1' },
253
+ { entityId: 'test:entity', recordId: 'rec-2' },
254
+ ],
255
+ })
256
+
257
+ await handleVectorIndexJob(job, createMockJobContext(), containerWithProgress)
258
+
259
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
260
+ // Skipped records are counted as processed so the reindex run still completes.
261
+ expect(updateReindexProgress).toHaveBeenCalledWith(mockDb, 'tenant-123', 'vector', 2, 'org-456')
262
+ expect(incrementReindexProgress).toHaveBeenCalledWith(
263
+ expect.objectContaining({ type: 'vector', tenantId: 'tenant-123', delta: 2 }),
264
+ )
265
+ expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'vector', 'org-456')
266
+ warnSpy.mockRestore()
267
+ })
268
+
269
+ it('should skip a single-record index on dimension mismatch without indexing or embedding', async () => {
270
+ mockTableDimension = 768
271
+ mockEmbeddingService.dimension = 1536
272
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
273
+ const job = createMockJob<VectorIndexJobPayload>({
274
+ jobType: 'index',
275
+ entityType: 'customers:customer_person_profile',
276
+ recordId: 'rec-123',
277
+ tenantId: 'tenant-123',
278
+ organizationId: 'org-456',
279
+ })
280
+
281
+ await handleVectorIndexJob(job, createMockJobContext(), mockContainer)
282
+
283
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
284
+ expect(mockEmbeddingService.createEmbedding).not.toHaveBeenCalled()
285
+ expect(warnSpy).toHaveBeenCalledTimes(1)
286
+ warnSpy.mockRestore()
287
+ })
288
+
289
+ it('should still delete a record even when the provider is misconfigured', async () => {
290
+ mockEmbeddingService.available = false
291
+ const job = createMockJob<VectorIndexJobPayload>({
292
+ jobType: 'delete',
293
+ entityType: 'customers:customer_person_profile',
294
+ recordId: 'rec-123',
295
+ tenantId: 'tenant-123',
296
+ organizationId: null,
297
+ })
298
+
299
+ await handleVectorIndexJob(job, createMockJobContext(), mockContainer)
300
+
301
+ expect(mockSearchIndexer.deleteRecord).toHaveBeenCalled()
302
+ })
151
303
  })
152
304
 
153
305
  describe('Fulltext Index Worker', () => {
package/src/lib/debug.ts CHANGED
@@ -33,6 +33,20 @@ export function searchDebugWarn(prefix: string, message: string, data?: Record<s
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Log a warning message (always logs, not gated by debug flag).
38
+ * Use for operational warnings that must stay visible without OM_SEARCH_DEBUG,
39
+ * such as skipping a vector-index run because the provider is unreachable or
40
+ * the configured embedding dimension no longer matches the vector table.
41
+ */
42
+ export function searchWarn(prefix: string, message: string, data?: Record<string, unknown>): void {
43
+ if (data) {
44
+ console.warn(`[${prefix}] ${message}`, data)
45
+ } else {
46
+ console.warn(`[${prefix}] ${message}`)
47
+ }
48
+ }
49
+
36
50
  /**
37
51
  * Log an error message (always logs, not gated by debug flag).
38
52
  * Errors should always be visible for troubleshooting.
@@ -2,7 +2,7 @@ import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
2
  import type { Kysely } from 'kysely'
3
3
  import { VECTOR_INDEXING_QUEUE_NAME, type VectorIndexJobPayload } from '../../../queue/vector-indexing'
4
4
  import type { SearchIndexer } from '../../../indexer/search-indexer'
5
- import type { EmbeddingService } from '../../../vector'
5
+ import type { EmbeddingService, VectorDriver } from '../../../vector'
6
6
  import type { EntityManager } from '@mikro-orm/postgresql'
7
7
 
8
8
  import type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'
@@ -11,7 +11,8 @@ import { applyCoverageAdjustments, createCoverageAdjustments } from '@open-merca
11
11
  import { logVectorOperation } from '../../../vector/lib/vector-logs'
12
12
  import { resolveAutoIndexingEnabled } from '../lib/auto-indexing'
13
13
  import { resolveEmbeddingConfig } from '../lib/embedding-config'
14
- import { searchDebugWarn } from '../../../lib/debug'
14
+ import { searchDebugWarn, searchWarn } from '../../../lib/debug'
15
+ import { evaluateVectorPreflight, type VectorPreflightResult } from '../../../vector/lib/preflight'
15
16
  import { clearReindexLock, updateReindexProgress } from '../lib/reindex-lock'
16
17
  import { incrementReindexProgress } from '../lib/reindex-progress'
17
18
 
@@ -26,6 +27,47 @@ export const metadata: WorkerMeta = {
26
27
 
27
28
  type HandlerContext = { resolve: <T = unknown>(name: string) => T }
28
29
 
30
+ /**
31
+ * Decide once per job whether vector work can succeed, so the worker can skip a
32
+ * doomed run with a single warning instead of failing every record. When the
33
+ * embedding service is not resolvable we return `ok` and let the existing
34
+ * strategy path decide, preserving prior behavior.
35
+ *
36
+ * `withProbe` issues one tiny embedding to detect an unreachable provider; use
37
+ * it for bulk reindex batches, not for hot single-record writes.
38
+ */
39
+ async function runVectorPreflight(
40
+ ctx: HandlerContext,
41
+ options: { withProbe: boolean },
42
+ ): Promise<VectorPreflightResult> {
43
+ let embeddingService: EmbeddingService | null = null
44
+ try {
45
+ embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')
46
+ } catch {
47
+ embeddingService = null
48
+ }
49
+ if (!embeddingService) return { ok: true }
50
+
51
+ let tableDimension: number | null = null
52
+ try {
53
+ const drivers = ctx.resolve<VectorDriver[]>('vectorDrivers')
54
+ const pgvectorDriver = drivers.find((driver) => driver.id === 'pgvector')
55
+ if (pgvectorDriver?.getTableDimension) {
56
+ tableDimension = await pgvectorDriver.getTableDimension()
57
+ }
58
+ } catch {
59
+ tableDimension = null
60
+ }
61
+
62
+ const service = embeddingService
63
+ return evaluateVectorPreflight({
64
+ providerConfigured: service.available,
65
+ effectiveDimension: typeof service.dimension === 'number' ? service.dimension : null,
66
+ tableDimension,
67
+ probe: options.withProbe ? () => service.createEmbedding('preflight') : undefined,
68
+ })
69
+ }
70
+
29
71
  /**
30
72
  * Process a vector index job.
31
73
  *
@@ -93,6 +135,37 @@ export async function handleVectorIndexJob(
93
135
  })
94
136
  }
95
137
 
138
+ // Preflight once: if the provider is unreachable/misconfigured or its
139
+ // dimension no longer matches the shared vector table, skip the whole batch
140
+ // with a single warning instead of failing every record. Still advance the
141
+ // reindex progress/lock so the run completes (records counted as processed).
142
+ const preflight = await runVectorPreflight(ctx, { withProbe: true })
143
+ if (!preflight.ok) {
144
+ searchWarn('vector-index.worker', `Skipping vector batch: ${preflight.reason}`, {
145
+ jobId: jobCtx.jobId,
146
+ code: preflight.code,
147
+ totalRecords: records.length,
148
+ tenantId,
149
+ })
150
+ if (db && records.length > 0) {
151
+ await updateReindexProgress(db, tenantId, 'vector', records.length, organizationId ?? null)
152
+ }
153
+ if (progressService && em && records.length > 0) {
154
+ const completed = await incrementReindexProgress({
155
+ em,
156
+ progressService,
157
+ type: 'vector',
158
+ tenantId,
159
+ organizationId: organizationId ?? null,
160
+ delta: records.length,
161
+ })
162
+ if (completed && db) {
163
+ await clearReindexLock(db, tenantId, 'vector', organizationId ?? null)
164
+ }
165
+ }
166
+ return
167
+ }
168
+
96
169
  // Process each record in the batch
97
170
  let successCount = 0
98
171
  let failCount = 0
@@ -184,6 +257,25 @@ export async function handleVectorIndexJob(
184
257
  }
185
258
  }
186
259
 
260
+ // Preflight index jobs only (delete never needs the provider): skip with a
261
+ // single warning when the provider is misconfigured or the configured
262
+ // dimension no longer matches the shared vector table. No reachability probe
263
+ // here — single-record writes are the hot path and the cheap checks already
264
+ // catch the common misconfiguration without an extra embedding call.
265
+ if (jobType === 'index') {
266
+ const preflight = await runVectorPreflight(ctx, { withProbe: false })
267
+ if (!preflight.ok) {
268
+ searchWarn('vector-index.worker', `Skipping vector index for record: ${preflight.reason}`, {
269
+ jobId: jobCtx.jobId,
270
+ code: preflight.code,
271
+ entityType,
272
+ recordId,
273
+ tenantId,
274
+ })
275
+ return
276
+ }
277
+ }
278
+
187
279
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
280
  let em: any | null = null
189
281
  try {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Vector indexing preflight.
3
+ *
4
+ * The embedding provider config and the pgvector table dimension are global
5
+ * (per-database), not per-tenant. When the configured provider is unreachable,
6
+ * not configured, or produces a dimension that no longer matches the shared
7
+ * vector table, every record in a reindex run fails the same way — producing a
8
+ * storm of per-record errors and wasted embedding calls.
9
+ *
10
+ * This helper lets a caller decide ONCE per run whether vector work can succeed,
11
+ * so it can skip with a single warning instead of failing every record. It is a
12
+ * pure function: the reachability probe is injected so it stays unit-testable.
13
+ */
14
+
15
+ export type VectorPreflightSkipCode =
16
+ | 'provider_not_configured'
17
+ | 'dimension_mismatch'
18
+ | 'provider_unreachable'
19
+
20
+ export type VectorPreflightInput = {
21
+ /** Whether the active embedding provider has its credentials/config present. */
22
+ providerConfigured: boolean
23
+ /** Dimension the active embedding config will produce (null when unknown). */
24
+ effectiveDimension: number | null
25
+ /** Dimension of the shared vector table (null when unknown/unavailable). */
26
+ tableDimension: number | null
27
+ /**
28
+ * Optional reachability probe. When provided it is invoked last and MUST
29
+ * throw if the provider cannot be reached. Omit it on hot paths (e.g.
30
+ * single-record indexing) where the extra embedding call is not worth it.
31
+ */
32
+ probe?: () => Promise<unknown>
33
+ }
34
+
35
+ export type VectorPreflightResult =
36
+ | { ok: true }
37
+ | { ok: false; code: VectorPreflightSkipCode; reason: string }
38
+
39
+ export async function evaluateVectorPreflight(
40
+ input: VectorPreflightInput,
41
+ ): Promise<VectorPreflightResult> {
42
+ if (!input.providerConfigured) {
43
+ return {
44
+ ok: false,
45
+ code: 'provider_not_configured',
46
+ reason:
47
+ 'embedding provider is not configured (missing API key/base URL); set the provider credentials or re-point the provider in Settings → Search',
48
+ }
49
+ }
50
+
51
+ if (
52
+ typeof input.effectiveDimension === 'number' &&
53
+ typeof input.tableDimension === 'number' &&
54
+ input.effectiveDimension !== input.tableDimension
55
+ ) {
56
+ return {
57
+ ok: false,
58
+ code: 'dimension_mismatch',
59
+ reason:
60
+ `configured provider produces ${input.effectiveDimension}-dim embeddings but the shared vector table is ${input.tableDimension}-dim; ` +
61
+ 're-point the provider in Settings → Search to recreate the table at the new dimension, then reindex',
62
+ }
63
+ }
64
+
65
+ if (input.probe) {
66
+ try {
67
+ await input.probe()
68
+ } catch (error) {
69
+ return {
70
+ ok: false,
71
+ code: 'provider_unreachable',
72
+ reason: `embedding provider is unreachable: ${error instanceof Error ? error.message : String(error)}`,
73
+ }
74
+ }
75
+ }
76
+
77
+ return { ok: true }
78
+ }
@@ -234,17 +234,26 @@ export class EmbeddingService {
234
234
  const providerOptions = this.getProviderOptions()
235
235
  const timeoutMs = resolveEmbeddingTimeoutMs()
236
236
 
237
+ const abortController = new AbortController()
237
238
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null
238
239
  try {
239
240
  const result = await Promise.race([
240
241
  embed({
241
242
  model,
242
243
  value: merged,
244
+ abortSignal: abortController.signal,
243
245
  ...(providerOptions && { providerOptions }),
244
246
  }),
245
247
  new Promise<never>((_, reject) => {
246
248
  timeoutHandle = setTimeout(
247
- () => reject(timeoutError(this.config.providerId, timeoutMs)),
249
+ () => {
250
+ // Abort the in-flight request so a dead/unreachable provider releases
251
+ // its socket — and, in a worker, the per-job DB connection held while
252
+ // this awaits — promptly, instead of lingering until the platform's
253
+ // default network timeout and pinning pool capacity under a storm.
254
+ abortController.abort()
255
+ reject(timeoutError(this.config.providerId, timeoutMs))
256
+ },
248
257
  timeoutMs,
249
258
  )
250
259
  }),