@open-mercato/search 0.6.4-develop.4239.1.4a264a5828 → 0.6.4-develop.4264.1.53368d85fe

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.
Files changed (33) hide show
  1. package/dist/fulltext/drivers/meilisearch/index.js +14 -2
  2. package/dist/fulltext/drivers/meilisearch/index.js.map +2 -2
  3. package/dist/lib/merger.js +10 -3
  4. package/dist/lib/merger.js.map +2 -2
  5. package/dist/modules/search/api/search/global/route.js +3 -0
  6. package/dist/modules/search/api/search/global/route.js.map +2 -2
  7. package/dist/modules/search/api/search/route.js +3 -0
  8. package/dist/modules/search/api/search/route.js.map +2 -2
  9. package/dist/service.js +25 -1
  10. package/dist/service.js.map +2 -2
  11. package/dist/strategies/fulltext.strategy.js +6 -0
  12. package/dist/strategies/fulltext.strategy.js.map +2 -2
  13. package/dist/strategies/token.strategy.js +16 -4
  14. package/dist/strategies/token.strategy.js.map +2 -2
  15. package/dist/strategies/vector.strategy.js +16 -2
  16. package/dist/strategies/vector.strategy.js.map +2 -2
  17. package/dist/vector/drivers/pgvector/index.js +8 -2
  18. package/dist/vector/drivers/pgvector/index.js.map +2 -2
  19. package/dist/vector/types.js.map +2 -2
  20. package/package.json +4 -4
  21. package/src/__tests__/service.test.ts +44 -0
  22. package/src/fulltext/drivers/meilisearch/index.ts +16 -2
  23. package/src/fulltext/types.ts +2 -0
  24. package/src/lib/merger.ts +9 -2
  25. package/src/modules/search/api/__tests__/org-scoping.routes.test.ts +29 -1
  26. package/src/modules/search/api/search/global/route.ts +3 -0
  27. package/src/modules/search/api/search/route.ts +3 -0
  28. package/src/service.ts +32 -1
  29. package/src/strategies/fulltext.strategy.ts +9 -0
  30. package/src/strategies/token.strategy.ts +25 -4
  31. package/src/strategies/vector.strategy.ts +21 -3
  32. package/src/vector/drivers/pgvector/index.ts +14 -2
  33. package/src/vector/types.ts +1 -0
@@ -34,8 +34,19 @@ function createMeilisearchDriver(options) {
34
34
  }
35
35
  function buildFilters(options2) {
36
36
  const filters = [];
37
- if (options2.organizationId) {
38
- filters.push(`_organizationId = "${escapeFilterValue(options2.organizationId)}"`);
37
+ const organizationId = typeof options2.organizationId === "string" ? options2.organizationId.trim() : "";
38
+ if (organizationId) {
39
+ filters.push(`_organizationId = "${escapeFilterValue(organizationId)}"`);
40
+ } else if (Array.isArray(options2.organizationIds)) {
41
+ const organizationIds = Array.from(new Set(
42
+ options2.organizationIds.map((value) => typeof value === "string" ? value.trim() : "").filter((value) => value.length > 0)
43
+ ));
44
+ if (organizationIds.length === 0) {
45
+ filters.push('_organizationId = "__open_mercato_no_matching_organization__"');
46
+ } else {
47
+ const orgFilter = organizationIds.map((id) => `"${escapeFilterValue(id)}"`).join(", ");
48
+ filters.push(`_organizationId IN [${orgFilter}]`);
49
+ }
39
50
  }
40
51
  if (options2.entityTypes?.length) {
41
52
  const entityFilter = options2.entityTypes.map((t) => `"${escapeFilterValue(t)}"`).join(", ");
@@ -155,6 +166,7 @@ function createMeilisearchDriver(options) {
155
166
  recordId: hit._id,
156
167
  entityId: hit._entityId,
157
168
  score: hit._rankingScore ?? 0.5,
169
+ organizationId: hit._organizationId,
158
170
  presenter: hit._presenter,
159
171
  url: hit._url,
160
172
  links: hit._links,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/fulltext/drivers/meilisearch/index.ts"],
4
- "sourcesContent": ["import { Meilisearch } from 'meilisearch'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchFieldPolicy } from '@open-mercato/shared/modules/search'\nimport { resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'\nimport type {\n FullTextSearchDriver,\n FullTextSearchDocument,\n FullTextSearchQuery,\n FullTextSearchHit,\n DocumentLookupKey,\n IndexStats,\n} from '../../types'\nimport { extractSearchableFields, type EncryptionMapEntry } from '../../../lib/field-policy'\n\n\nexport type MeilisearchDriverOptions = {\n host?: string\n apiKey?: string\n indexPrefix?: string\n defaultLimit?: number\n timeoutMs?: number\n encryptionMapResolver?: (entityId: EntityId) => Promise<EncryptionMapEntry[]>\n fieldPolicyResolver?: (entityId: EntityId) => SearchFieldPolicy | undefined\n}\n\nconst DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS = 30_000\n\nfunction resolveMeilisearchTimeoutMs(explicit?: number): number {\n if (typeof explicit === 'number') return resolveTimeoutMs(explicit, DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS)\n const raw = process.env.MEILISEARCH_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS)\n}\n\nexport function createMeilisearchDriver(\n options?: MeilisearchDriverOptions\n): FullTextSearchDriver {\n const host = options?.host ?? process.env.MEILISEARCH_HOST ?? ''\n const apiKey = options?.apiKey ?? process.env.MEILISEARCH_API_KEY ?? ''\n const indexPrefix = options?.indexPrefix ?? process.env.MEILISEARCH_INDEX_PREFIX ?? 'om'\n const defaultLimit = options?.defaultLimit ?? 20\n const requestTimeoutMs = resolveMeilisearchTimeoutMs(options?.timeoutMs)\n const encryptionMapResolver = options?.encryptionMapResolver\n const fieldPolicyResolver = options?.fieldPolicyResolver\n\n let client: Meilisearch | null = null\n const initializedIndexes = new Set<string>()\n const initializingIndexes = new Map<string, Promise<void>>()\n\n function getClient(): Meilisearch {\n if (!client) {\n client = new Meilisearch({ host, apiKey, timeout: requestTimeoutMs })\n }\n return client\n }\n\n function buildIndexName(tenantId: string): string {\n const sanitized = tenantId.replace(/[^a-zA-Z0-9_-]/g, '_')\n return `${indexPrefix}_${sanitized}`\n }\n\n function escapeFilterValue(value: string): string {\n return value.replace(/[\"\\\\]/g, '\\\\$&')\n }\n\n function buildFilters(options: FullTextSearchQuery): string[] {\n const filters: string[] = []\n\n if (options.organizationId) {\n filters.push(`_organizationId = \"${escapeFilterValue(options.organizationId)}\"`)\n }\n\n if (options.entityTypes?.length) {\n const entityFilter = options.entityTypes.map((t) => `\"${escapeFilterValue(t)}\"`).join(', ')\n filters.push(`_entityId IN [${entityFilter}]`)\n }\n\n return filters\n }\n\n async function doEnsureIndex(indexName: string): Promise<void> {\n const meiliClient = getClient()\n\n try {\n await meiliClient.createIndex(indexName, { primaryKey: '_id' })\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code !== 'index_already_exists') {\n throw error\n }\n }\n\n const index = meiliClient.index(indexName)\n await index.updateSettings({\n searchableAttributes: ['*'],\n filterableAttributes: ['_entityId', '_organizationId'],\n sortableAttributes: ['_indexedAt'],\n typoTolerance: {\n enabled: true,\n minWordSizeForTypos: {\n oneTypo: 4,\n twoTypos: 8,\n },\n },\n })\n\n initializedIndexes.add(indexName)\n }\n\n async function ensureIndex(indexName: string): Promise<void> {\n if (initializedIndexes.has(indexName)) {\n return\n }\n\n const existingPromise = initializingIndexes.get(indexName)\n if (existingPromise) {\n return existingPromise\n }\n\n const initPromise = doEnsureIndex(indexName)\n initializingIndexes.set(indexName, initPromise)\n\n try {\n await initPromise\n } finally {\n initializingIndexes.delete(indexName)\n }\n }\n\n async function prepareDocument(doc: FullTextSearchDocument): Promise<Record<string, unknown>> {\n // When encryptionMapResolver is provided, SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled\n const excludeEncrypted = Boolean(encryptionMapResolver)\n const encryptedFields = encryptionMapResolver\n ? await encryptionMapResolver(doc.entityId)\n : []\n const fieldPolicy = fieldPolicyResolver?.(doc.entityId)\n\n const searchableFields = extractSearchableFields(doc.fields, {\n encryptedFields,\n fieldPolicy,\n })\n\n // When SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled:\n // - Exclude sensitive parts of presenter (title, subtitle) - these are derived from encrypted fields\n // - Keep non-sensitive parts (icon, badge)\n // - Sanitize link labels (they often contain names derived from encrypted fields)\n // - Title/subtitle/link labels will be enriched at search time from the database\n let presenter = doc.presenter\n let links = doc.links\n if (excludeEncrypted) {\n if (presenter) {\n presenter = {\n ...presenter,\n title: '', // Will be enriched at search time\n subtitle: undefined, // Will be enriched at search time\n }\n }\n // Sanitize link labels - they often contain sensitive data (names, etc.)\n if (links && links.length > 0) {\n links = links.map((link) => ({\n ...link,\n label: link.kind === 'primary' ? 'Open' : 'View', // Generic labels\n }))\n }\n }\n\n return {\n _id: doc.recordId,\n _entityId: doc.entityId,\n _organizationId: doc.organizationId,\n _presenter: presenter,\n _url: doc.url,\n _links: links,\n _indexedAt: new Date().toISOString(),\n ...searchableFields,\n }\n }\n\n const driver: FullTextSearchDriver = {\n id: 'meilisearch',\n\n async ensureReady(): Promise<void> {\n // Client is lazily initialized\n },\n\n async isHealthy(): Promise<boolean> {\n if (!host) {\n return false\n }\n\n try {\n const meiliClient = getClient()\n await meiliClient.health()\n return true\n } catch {\n return false\n }\n },\n\n async search(query: string, options: FullTextSearchQuery): Promise<FullTextSearchHit[]> {\n const meiliClient = getClient()\n const indexName = buildIndexName(options.tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const filters = buildFilters(options)\n\n const response = await index.search(query, {\n limit: options.limit ?? defaultLimit,\n offset: options.offset,\n filter: filters.length > 0 ? filters.join(' AND ') : undefined,\n showRankingScore: true,\n })\n\n return response.hits.map((hit: Record<string, unknown>) => ({\n recordId: hit._id as string,\n entityId: hit._entityId as EntityId,\n score: (hit._rankingScore as number) ?? 0.5,\n presenter: hit._presenter as FullTextSearchHit['presenter'],\n url: hit._url as string | undefined,\n links: hit._links as FullTextSearchHit['links'],\n metadata: hit._metadata as Record<string, unknown> | undefined,\n }))\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return []\n }\n throw error\n }\n },\n\n async index(doc: FullTextSearchDocument): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(doc.tenantId)\n\n await ensureIndex(indexName)\n\n const document = await prepareDocument(doc)\n\n const index = meiliClient.index(indexName)\n await index.addDocuments([document], { primaryKey: '_id' })\n },\n\n async delete(recordId: string, tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocument(recordId)\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async bulkIndex(docs: FullTextSearchDocument[]): Promise<void> {\n if (docs.length === 0) return\n\n // Group documents by tenant\n const byTenant = new Map<string, FullTextSearchDocument[]>()\n for (const doc of docs) {\n const list = byTenant.get(doc.tenantId) ?? []\n list.push(doc)\n byTenant.set(doc.tenantId, list)\n }\n\n const meiliClient = getClient()\n\n for (const [tenantId, tenantDocs] of byTenant) {\n const indexName = buildIndexName(tenantId)\n await ensureIndex(indexName)\n\n const documents = await Promise.all(tenantDocs.map(prepareDocument))\n\n const index = meiliClient.index(indexName)\n await index.addDocuments(documents, { primaryKey: '_id' })\n }\n },\n\n async purge(entityId: EntityId, tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocuments({\n filter: `_entityId = \"${entityId}\"`,\n })\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async clearIndex(tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteAllDocuments()\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async recreateIndex(tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n initializedIndexes.delete(indexName)\n\n try {\n await meiliClient.deleteIndex(indexName)\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code !== 'index_not_found') {\n throw error\n }\n }\n\n await ensureIndex(indexName)\n },\n\n async getDocuments(\n ids: DocumentLookupKey[],\n tenantId: string\n ): Promise<Map<string, FullTextSearchHit>> {\n const result = new Map<string, FullTextSearchHit>()\n if (ids.length === 0) return result\n\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n\n const recordIds = ids.map((id) => id.recordId)\n const documents = await index.getDocuments({\n filter: `_id IN [${recordIds.map((id) => `\"${id}\"`).join(', ')}]`,\n limit: recordIds.length,\n })\n\n for (const doc of documents.results) {\n const hit = doc as Record<string, unknown>\n const key = `${hit._entityId}:${hit._id}`\n result.set(key, {\n recordId: hit._id as string,\n entityId: hit._entityId as EntityId,\n score: 0,\n presenter: hit._presenter as FullTextSearchHit['presenter'],\n url: hit._url as string | undefined,\n links: hit._links as FullTextSearchHit['links'],\n })\n }\n } catch {\n // Index not found or error, return empty map\n }\n\n return result\n },\n\n async getIndexStats(tenantId: string): Promise<IndexStats | null> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const stats = await index.getStats()\n return {\n numberOfDocuments: stats.numberOfDocuments,\n isIndexing: stats.isIndexing,\n fieldDistribution: stats.fieldDistribution,\n }\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return null\n }\n throw error\n }\n },\n\n async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const searchResult = await index.search('', {\n limit: 0,\n facets: ['_entityId'],\n })\n const facetDistribution = searchResult.facetDistribution?._entityId\n if (!facetDistribution) {\n return {}\n }\n return facetDistribution\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return null\n }\n throw error\n }\n },\n }\n\n return driver\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { Meilisearch } from 'meilisearch'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchFieldPolicy } from '@open-mercato/shared/modules/search'\nimport { resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout'\nimport type {\n FullTextSearchDriver,\n FullTextSearchDocument,\n FullTextSearchQuery,\n FullTextSearchHit,\n DocumentLookupKey,\n IndexStats,\n} from '../../types'\nimport { extractSearchableFields, type EncryptionMapEntry } from '../../../lib/field-policy'\n\n\nexport type MeilisearchDriverOptions = {\n host?: string\n apiKey?: string\n indexPrefix?: string\n defaultLimit?: number\n timeoutMs?: number\n encryptionMapResolver?: (entityId: EntityId) => Promise<EncryptionMapEntry[]>\n fieldPolicyResolver?: (entityId: EntityId) => SearchFieldPolicy | undefined\n}\n\nconst DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS = 30_000\n\nfunction resolveMeilisearchTimeoutMs(explicit?: number): number {\n if (typeof explicit === 'number') return resolveTimeoutMs(explicit, DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS)\n const raw = process.env.MEILISEARCH_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_MEILISEARCH_REQUEST_TIMEOUT_MS)\n}\n\nexport function createMeilisearchDriver(\n options?: MeilisearchDriverOptions\n): FullTextSearchDriver {\n const host = options?.host ?? process.env.MEILISEARCH_HOST ?? ''\n const apiKey = options?.apiKey ?? process.env.MEILISEARCH_API_KEY ?? ''\n const indexPrefix = options?.indexPrefix ?? process.env.MEILISEARCH_INDEX_PREFIX ?? 'om'\n const defaultLimit = options?.defaultLimit ?? 20\n const requestTimeoutMs = resolveMeilisearchTimeoutMs(options?.timeoutMs)\n const encryptionMapResolver = options?.encryptionMapResolver\n const fieldPolicyResolver = options?.fieldPolicyResolver\n\n let client: Meilisearch | null = null\n const initializedIndexes = new Set<string>()\n const initializingIndexes = new Map<string, Promise<void>>()\n\n function getClient(): Meilisearch {\n if (!client) {\n client = new Meilisearch({ host, apiKey, timeout: requestTimeoutMs })\n }\n return client\n }\n\n function buildIndexName(tenantId: string): string {\n const sanitized = tenantId.replace(/[^a-zA-Z0-9_-]/g, '_')\n return `${indexPrefix}_${sanitized}`\n }\n\n function escapeFilterValue(value: string): string {\n return value.replace(/[\"\\\\]/g, '\\\\$&')\n }\n\n function buildFilters(options: FullTextSearchQuery): string[] {\n const filters: string[] = []\n\n const organizationId = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (organizationId) {\n filters.push(`_organizationId = \"${escapeFilterValue(organizationId)}\"`)\n } else if (Array.isArray(options.organizationIds)) {\n const organizationIds = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n if (organizationIds.length === 0) {\n filters.push('_organizationId = \"__open_mercato_no_matching_organization__\"')\n } else {\n const orgFilter = organizationIds.map((id) => `\"${escapeFilterValue(id)}\"`).join(', ')\n filters.push(`_organizationId IN [${orgFilter}]`)\n }\n }\n\n if (options.entityTypes?.length) {\n const entityFilter = options.entityTypes.map((t) => `\"${escapeFilterValue(t)}\"`).join(', ')\n filters.push(`_entityId IN [${entityFilter}]`)\n }\n\n return filters\n }\n\n async function doEnsureIndex(indexName: string): Promise<void> {\n const meiliClient = getClient()\n\n try {\n await meiliClient.createIndex(indexName, { primaryKey: '_id' })\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code !== 'index_already_exists') {\n throw error\n }\n }\n\n const index = meiliClient.index(indexName)\n await index.updateSettings({\n searchableAttributes: ['*'],\n filterableAttributes: ['_entityId', '_organizationId'],\n sortableAttributes: ['_indexedAt'],\n typoTolerance: {\n enabled: true,\n minWordSizeForTypos: {\n oneTypo: 4,\n twoTypos: 8,\n },\n },\n })\n\n initializedIndexes.add(indexName)\n }\n\n async function ensureIndex(indexName: string): Promise<void> {\n if (initializedIndexes.has(indexName)) {\n return\n }\n\n const existingPromise = initializingIndexes.get(indexName)\n if (existingPromise) {\n return existingPromise\n }\n\n const initPromise = doEnsureIndex(indexName)\n initializingIndexes.set(indexName, initPromise)\n\n try {\n await initPromise\n } finally {\n initializingIndexes.delete(indexName)\n }\n }\n\n async function prepareDocument(doc: FullTextSearchDocument): Promise<Record<string, unknown>> {\n // When encryptionMapResolver is provided, SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled\n const excludeEncrypted = Boolean(encryptionMapResolver)\n const encryptedFields = encryptionMapResolver\n ? await encryptionMapResolver(doc.entityId)\n : []\n const fieldPolicy = fieldPolicyResolver?.(doc.entityId)\n\n const searchableFields = extractSearchableFields(doc.fields, {\n encryptedFields,\n fieldPolicy,\n })\n\n // When SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled:\n // - Exclude sensitive parts of presenter (title, subtitle) - these are derived from encrypted fields\n // - Keep non-sensitive parts (icon, badge)\n // - Sanitize link labels (they often contain names derived from encrypted fields)\n // - Title/subtitle/link labels will be enriched at search time from the database\n let presenter = doc.presenter\n let links = doc.links\n if (excludeEncrypted) {\n if (presenter) {\n presenter = {\n ...presenter,\n title: '', // Will be enriched at search time\n subtitle: undefined, // Will be enriched at search time\n }\n }\n // Sanitize link labels - they often contain sensitive data (names, etc.)\n if (links && links.length > 0) {\n links = links.map((link) => ({\n ...link,\n label: link.kind === 'primary' ? 'Open' : 'View', // Generic labels\n }))\n }\n }\n\n return {\n _id: doc.recordId,\n _entityId: doc.entityId,\n _organizationId: doc.organizationId,\n _presenter: presenter,\n _url: doc.url,\n _links: links,\n _indexedAt: new Date().toISOString(),\n ...searchableFields,\n }\n }\n\n const driver: FullTextSearchDriver = {\n id: 'meilisearch',\n\n async ensureReady(): Promise<void> {\n // Client is lazily initialized\n },\n\n async isHealthy(): Promise<boolean> {\n if (!host) {\n return false\n }\n\n try {\n const meiliClient = getClient()\n await meiliClient.health()\n return true\n } catch {\n return false\n }\n },\n\n async search(query: string, options: FullTextSearchQuery): Promise<FullTextSearchHit[]> {\n const meiliClient = getClient()\n const indexName = buildIndexName(options.tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const filters = buildFilters(options)\n\n const response = await index.search(query, {\n limit: options.limit ?? defaultLimit,\n offset: options.offset,\n filter: filters.length > 0 ? filters.join(' AND ') : undefined,\n showRankingScore: true,\n })\n\n return response.hits.map((hit: Record<string, unknown>) => ({\n recordId: hit._id as string,\n entityId: hit._entityId as EntityId,\n score: (hit._rankingScore as number) ?? 0.5,\n organizationId: hit._organizationId as string | null | undefined,\n presenter: hit._presenter as FullTextSearchHit['presenter'],\n url: hit._url as string | undefined,\n links: hit._links as FullTextSearchHit['links'],\n metadata: hit._metadata as Record<string, unknown> | undefined,\n }))\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return []\n }\n throw error\n }\n },\n\n async index(doc: FullTextSearchDocument): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(doc.tenantId)\n\n await ensureIndex(indexName)\n\n const document = await prepareDocument(doc)\n\n const index = meiliClient.index(indexName)\n await index.addDocuments([document], { primaryKey: '_id' })\n },\n\n async delete(recordId: string, tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocument(recordId)\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async bulkIndex(docs: FullTextSearchDocument[]): Promise<void> {\n if (docs.length === 0) return\n\n // Group documents by tenant\n const byTenant = new Map<string, FullTextSearchDocument[]>()\n for (const doc of docs) {\n const list = byTenant.get(doc.tenantId) ?? []\n list.push(doc)\n byTenant.set(doc.tenantId, list)\n }\n\n const meiliClient = getClient()\n\n for (const [tenantId, tenantDocs] of byTenant) {\n const indexName = buildIndexName(tenantId)\n await ensureIndex(indexName)\n\n const documents = await Promise.all(tenantDocs.map(prepareDocument))\n\n const index = meiliClient.index(indexName)\n await index.addDocuments(documents, { primaryKey: '_id' })\n }\n },\n\n async purge(entityId: EntityId, tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocuments({\n filter: `_entityId = \"${entityId}\"`,\n })\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async clearIndex(tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteAllDocuments()\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return\n }\n throw error\n }\n },\n\n async recreateIndex(tenantId: string): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n initializedIndexes.delete(indexName)\n\n try {\n await meiliClient.deleteIndex(indexName)\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code !== 'index_not_found') {\n throw error\n }\n }\n\n await ensureIndex(indexName)\n },\n\n async getDocuments(\n ids: DocumentLookupKey[],\n tenantId: string\n ): Promise<Map<string, FullTextSearchHit>> {\n const result = new Map<string, FullTextSearchHit>()\n if (ids.length === 0) return result\n\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n\n const recordIds = ids.map((id) => id.recordId)\n const documents = await index.getDocuments({\n filter: `_id IN [${recordIds.map((id) => `\"${id}\"`).join(', ')}]`,\n limit: recordIds.length,\n })\n\n for (const doc of documents.results) {\n const hit = doc as Record<string, unknown>\n const key = `${hit._entityId}:${hit._id}`\n result.set(key, {\n recordId: hit._id as string,\n entityId: hit._entityId as EntityId,\n score: 0,\n presenter: hit._presenter as FullTextSearchHit['presenter'],\n url: hit._url as string | undefined,\n links: hit._links as FullTextSearchHit['links'],\n })\n }\n } catch {\n // Index not found or error, return empty map\n }\n\n return result\n },\n\n async getIndexStats(tenantId: string): Promise<IndexStats | null> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const stats = await index.getStats()\n return {\n numberOfDocuments: stats.numberOfDocuments,\n isIndexing: stats.isIndexing,\n fieldDistribution: stats.fieldDistribution,\n }\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return null\n }\n throw error\n }\n },\n\n async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n\n try {\n const index = meiliClient.index(indexName)\n const searchResult = await index.search('', {\n limit: 0,\n facets: ['_entityId'],\n })\n const facetDistribution = searchResult.facetDistribution?._entityId\n if (!facetDistribution) {\n return {}\n }\n return facetDistribution\n } catch (error: unknown) {\n const meilisearchError = error as { code?: string }\n if (meilisearchError.code === 'index_not_found') {\n return null\n }\n throw error\n }\n },\n }\n\n return driver\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["options"]
7
7
  }
@@ -25,14 +25,21 @@ function mergeAndRankResults(results, config) {
25
25
  ...existing.result,
26
26
  presenter: result.presenter,
27
27
  url: existing.result.url ?? result.url,
28
- links: existing.result.links ?? result.links
28
+ links: existing.result.links ?? result.links,
29
+ organizationId: existing.result.organizationId ?? result.organizationId
29
30
  };
30
31
  existing.bestContribution = Math.max(existing.bestContribution, rrfScore);
31
32
  } else if (hasExistingPresenter && hasNewPresenter && rrfScore > existing.bestContribution) {
32
- existing.result = { ...result };
33
+ existing.result = {
34
+ ...result,
35
+ organizationId: result.organizationId ?? existing.result.organizationId
36
+ };
33
37
  existing.bestContribution = rrfScore;
34
38
  } else if (!hasExistingPresenter && !hasNewPresenter && rrfScore > existing.bestContribution) {
35
- existing.result = { ...result };
39
+ existing.result = {
40
+ ...result,
41
+ organizationId: result.organizationId ?? existing.result.organizationId
42
+ };
36
43
  existing.bestContribution = rrfScore;
37
44
  }
38
45
  } else {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/merger.ts"],
4
- "sourcesContent": ["import type { SearchResult, ResultMergeConfig, SearchStrategyId } from '../types'\n\n/**\n * Default RRF constant (k=60 is standard in literature).\n * Higher values reduce the influence of ranking position.\n */\nconst RRF_K = 60\n\n/**\n * Reciprocal Rank Fusion (RRF) algorithm for combining results from multiple search strategies.\n *\n * RRF is a simple but effective method for combining ranked lists. For each result,\n * it computes: score = sum(weight / (k + rank)) across all lists containing that result.\n *\n * Reference: Cormack, G.V., Clarke, C.L.A., & Buettcher, S. (2009).\n * \"Reciprocal rank fusion outperforms condorcet and individual rank learning methods\"\n * https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf\n *\n * @param results - Array of search results from multiple strategies\n * @param config - Merge configuration with weights and thresholds\n * @returns Merged and ranked results\n */\nexport function mergeAndRankResults(\n results: SearchResult[],\n config: ResultMergeConfig,\n): SearchResult[] {\n if (results.length === 0) return []\n\n // Group results by source strategy for rank calculation\n const bySource = new Map<SearchStrategyId, SearchResult[]>()\n for (const result of results) {\n const list = bySource.get(result.source) ?? []\n list.push(result)\n bySource.set(result.source, list)\n }\n\n // Track seen results with their RRF scores\n // bestContribution tracks the highest single RRF contribution for the kept result object\n const seen = new Map<string, { result: SearchResult; rrf: number; sources: Set<SearchStrategyId>; bestContribution: number }>()\n\n // Calculate RRF score for each result\n for (const [source, sourceResults] of bySource) {\n const weight = config.strategyWeights?.[source] ?? 1.0\n\n for (let rank = 0; rank < sourceResults.length; rank++) {\n const result = sourceResults[rank]\n const key = `${result.entityId}:${result.recordId}`\n const rrfScore = weight / (RRF_K + rank + 1)\n\n const existing = seen.get(key)\n if (existing) {\n // Combine RRF scores for duplicates found in multiple strategies\n existing.rrf += rrfScore\n existing.sources.add(source)\n\n // Merge presenter data - prefer result that has it\n // This ensures token results get enriched with presenter from meilisearch/vector\n const hasExistingPresenter = existing.result.presenter?.title != null\n const hasNewPresenter = result.presenter?.title != null\n\n if (!hasExistingPresenter && hasNewPresenter) {\n // Current result has no presenter, new one does - take new one's presenter\n existing.result = {\n ...existing.result,\n presenter: result.presenter,\n url: existing.result.url ?? result.url,\n links: existing.result.links ?? result.links,\n }\n existing.bestContribution = Math.max(existing.bestContribution, rrfScore)\n } else if (hasExistingPresenter && hasNewPresenter && rrfScore > existing.bestContribution) {\n // Both have presenter, keep the one with better RRF contribution (not raw score)\n existing.result = { ...result }\n existing.bestContribution = rrfScore\n } else if (!hasExistingPresenter && !hasNewPresenter && rrfScore > existing.bestContribution) {\n // Neither has presenter, keep result with better RRF contribution\n existing.result = { ...result }\n existing.bestContribution = rrfScore\n }\n // If existing has presenter and new doesn't, keep existing (do nothing)\n } else {\n seen.set(key, {\n result: { ...result },\n rrf: rrfScore,\n sources: new Set([source]),\n bestContribution: rrfScore,\n })\n }\n }\n }\n\n // Convert to array with final RRF scores\n let merged = Array.from(seen.values()).map(({ result, rrf, sources }) => ({\n ...result,\n score: rrf,\n metadata: {\n ...result.metadata,\n _sources: Array.from(sources),\n _rrfScore: rrf,\n },\n }))\n\n // Apply minimum score threshold\n if (config.minScore != null) {\n merged = merged.filter((r) => r.score >= config.minScore!)\n }\n\n // Sort by RRF score descending\n merged.sort((a, b) => b.score - a.score)\n\n return merged\n}\n\n/**\n * Simple deduplication without RRF scoring.\n * Keeps the highest-scored result for each entity+record pair.\n *\n * @param results - Array of search results\n * @returns Deduplicated results sorted by score\n */\nexport function deduplicateResults(results: SearchResult[]): SearchResult[] {\n const seen = new Map<string, SearchResult>()\n\n for (const result of results) {\n const key = `${result.entityId}:${result.recordId}`\n const existing = seen.get(key)\n\n if (!existing || result.score > existing.score) {\n seen.set(key, result)\n }\n }\n\n return Array.from(seen.values()).sort((a, b) => b.score - a.score)\n}\n\n/**\n * Normalize scores to 0-1 range using min-max normalization.\n * Useful when combining strategies with different score scales.\n *\n * @param results - Array of search results\n * @returns Results with normalized scores\n */\nexport function normalizeScores(results: SearchResult[]): SearchResult[] {\n if (results.length === 0) return []\n\n const scores = results.map((r) => r.score)\n const minScore = Math.min(...scores)\n const maxScore = Math.max(...scores)\n const range = maxScore - minScore\n\n if (range === 0) {\n // All scores are the same, normalize to 1.0\n return results.map((r) => ({ ...r, score: 1.0 }))\n }\n\n return results.map((r) => ({\n ...r,\n score: (r.score - minScore) / range,\n }))\n}\n"],
5
- "mappings": "AAMA,MAAM,QAAQ;AAgBP,SAAS,oBACd,SACA,QACgB;AAChB,MAAI,QAAQ,WAAW,EAAG,QAAO,CAAC;AAGlC,QAAM,WAAW,oBAAI,IAAsC;AAC3D,aAAW,UAAU,SAAS;AAC5B,UAAM,OAAO,SAAS,IAAI,OAAO,MAAM,KAAK,CAAC;AAC7C,SAAK,KAAK,MAAM;AAChB,aAAS,IAAI,OAAO,QAAQ,IAAI;AAAA,EAClC;AAIA,QAAM,OAAO,oBAAI,IAA6G;AAG9H,aAAW,CAAC,QAAQ,aAAa,KAAK,UAAU;AAC9C,UAAM,SAAS,OAAO,kBAAkB,MAAM,KAAK;AAEnD,aAAS,OAAO,GAAG,OAAO,cAAc,QAAQ,QAAQ;AACtD,YAAM,SAAS,cAAc,IAAI;AACjC,YAAM,MAAM,GAAG,OAAO,QAAQ,IAAI,OAAO,QAAQ;AACjD,YAAM,WAAW,UAAU,QAAQ,OAAO;AAE1C,YAAM,WAAW,KAAK,IAAI,GAAG;AAC7B,UAAI,UAAU;AAEZ,iBAAS,OAAO;AAChB,iBAAS,QAAQ,IAAI,MAAM;AAI3B,cAAM,uBAAuB,SAAS,OAAO,WAAW,SAAS;AACjE,cAAM,kBAAkB,OAAO,WAAW,SAAS;AAEnD,YAAI,CAAC,wBAAwB,iBAAiB;AAE5C,mBAAS,SAAS;AAAA,YAChB,GAAG,SAAS;AAAA,YACZ,WAAW,OAAO;AAAA,YAClB,KAAK,SAAS,OAAO,OAAO,OAAO;AAAA,YACnC,OAAO,SAAS,OAAO,SAAS,OAAO;AAAA,UACzC;AACA,mBAAS,mBAAmB,KAAK,IAAI,SAAS,kBAAkB,QAAQ;AAAA,QAC1E,WAAW,wBAAwB,mBAAmB,WAAW,SAAS,kBAAkB;AAE1F,mBAAS,SAAS,EAAE,GAAG,OAAO;AAC9B,mBAAS,mBAAmB;AAAA,QAC9B,WAAW,CAAC,wBAAwB,CAAC,mBAAmB,WAAW,SAAS,kBAAkB;AAE5F,mBAAS,SAAS,EAAE,GAAG,OAAO;AAC9B,mBAAS,mBAAmB;AAAA,QAC9B;AAAA,MAEF,OAAO;AACL,aAAK,IAAI,KAAK;AAAA,UACZ,QAAQ,EAAE,GAAG,OAAO;AAAA,UACpB,KAAK;AAAA,UACL,SAAS,oBAAI,IAAI,CAAC,MAAM,CAAC;AAAA,UACzB,kBAAkB;AAAA,QACpB,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAGA,MAAI,SAAS,MAAM,KAAK,KAAK,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,QAAQ,KAAK,QAAQ,OAAO;AAAA,IACxE,GAAG;AAAA,IACH,OAAO;AAAA,IACP,UAAU;AAAA,MACR,GAAG,OAAO;AAAA,MACV,UAAU,MAAM,KAAK,OAAO;AAAA,MAC5B,WAAW;AAAA,IACb;AAAA,EACF,EAAE;AAGF,MAAI,OAAO,YAAY,MAAM;AAC3B,aAAS,OAAO,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO,QAAS;AAAA,EAC3D;AAGA,SAAO,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAEvC,SAAO;AACT;AASO,SAAS,mBAAmB,SAAyC;AAC1E,QAAM,OAAO,oBAAI,IAA0B;AAE3C,aAAW,UAAU,SAAS;AAC5B,UAAM,MAAM,GAAG,OAAO,QAAQ,IAAI,OAAO,QAAQ;AACjD,UAAM,WAAW,KAAK,IAAI,GAAG;AAE7B,QAAI,CAAC,YAAY,OAAO,QAAQ,SAAS,OAAO;AAC9C,WAAK,IAAI,KAAK,MAAM;AAAA,IACtB;AAAA,EACF;AAEA,SAAO,MAAM,KAAK,KAAK,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AACnE;AASO,SAAS,gBAAgB,SAAyC;AACvE,MAAI,QAAQ,WAAW,EAAG,QAAO,CAAC;AAElC,QAAM,SAAS,QAAQ,IAAI,CAAC,MAAM,EAAE,KAAK;AACzC,QAAM,WAAW,KAAK,IAAI,GAAG,MAAM;AACnC,QAAM,WAAW,KAAK,IAAI,GAAG,MAAM;AACnC,QAAM,QAAQ,WAAW;AAEzB,MAAI,UAAU,GAAG;AAEf,WAAO,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,EAAI,EAAE;AAAA,EAClD;AAEA,SAAO,QAAQ,IAAI,CAAC,OAAO;AAAA,IACzB,GAAG;AAAA,IACH,QAAQ,EAAE,QAAQ,YAAY;AAAA,EAChC,EAAE;AACJ;",
4
+ "sourcesContent": ["import type { SearchResult, ResultMergeConfig, SearchStrategyId } from '../types'\n\n/**\n * Default RRF constant (k=60 is standard in literature).\n * Higher values reduce the influence of ranking position.\n */\nconst RRF_K = 60\n\n/**\n * Reciprocal Rank Fusion (RRF) algorithm for combining results from multiple search strategies.\n *\n * RRF is a simple but effective method for combining ranked lists. For each result,\n * it computes: score = sum(weight / (k + rank)) across all lists containing that result.\n *\n * Reference: Cormack, G.V., Clarke, C.L.A., & Buettcher, S. (2009).\n * \"Reciprocal rank fusion outperforms condorcet and individual rank learning methods\"\n * https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf\n *\n * @param results - Array of search results from multiple strategies\n * @param config - Merge configuration with weights and thresholds\n * @returns Merged and ranked results\n */\nexport function mergeAndRankResults(\n results: SearchResult[],\n config: ResultMergeConfig,\n): SearchResult[] {\n if (results.length === 0) return []\n\n // Group results by source strategy for rank calculation\n const bySource = new Map<SearchStrategyId, SearchResult[]>()\n for (const result of results) {\n const list = bySource.get(result.source) ?? []\n list.push(result)\n bySource.set(result.source, list)\n }\n\n // Track seen results with their RRF scores\n // bestContribution tracks the highest single RRF contribution for the kept result object\n const seen = new Map<string, { result: SearchResult; rrf: number; sources: Set<SearchStrategyId>; bestContribution: number }>()\n\n // Calculate RRF score for each result\n for (const [source, sourceResults] of bySource) {\n const weight = config.strategyWeights?.[source] ?? 1.0\n\n for (let rank = 0; rank < sourceResults.length; rank++) {\n const result = sourceResults[rank]\n const key = `${result.entityId}:${result.recordId}`\n const rrfScore = weight / (RRF_K + rank + 1)\n\n const existing = seen.get(key)\n if (existing) {\n // Combine RRF scores for duplicates found in multiple strategies\n existing.rrf += rrfScore\n existing.sources.add(source)\n\n // Merge presenter data - prefer result that has it\n // This ensures token results get enriched with presenter from meilisearch/vector\n const hasExistingPresenter = existing.result.presenter?.title != null\n const hasNewPresenter = result.presenter?.title != null\n\n if (!hasExistingPresenter && hasNewPresenter) {\n // Current result has no presenter, new one does - take new one's presenter\n existing.result = {\n ...existing.result,\n presenter: result.presenter,\n url: existing.result.url ?? result.url,\n links: existing.result.links ?? result.links,\n organizationId: existing.result.organizationId ?? result.organizationId,\n }\n existing.bestContribution = Math.max(existing.bestContribution, rrfScore)\n } else if (hasExistingPresenter && hasNewPresenter && rrfScore > existing.bestContribution) {\n // Both have presenter, keep the one with better RRF contribution (not raw score)\n existing.result = {\n ...result,\n organizationId: result.organizationId ?? existing.result.organizationId,\n }\n existing.bestContribution = rrfScore\n } else if (!hasExistingPresenter && !hasNewPresenter && rrfScore > existing.bestContribution) {\n // Neither has presenter, keep result with better RRF contribution\n existing.result = {\n ...result,\n organizationId: result.organizationId ?? existing.result.organizationId,\n }\n existing.bestContribution = rrfScore\n }\n // If existing has presenter and new doesn't, keep existing (do nothing)\n } else {\n seen.set(key, {\n result: { ...result },\n rrf: rrfScore,\n sources: new Set([source]),\n bestContribution: rrfScore,\n })\n }\n }\n }\n\n // Convert to array with final RRF scores\n let merged = Array.from(seen.values()).map(({ result, rrf, sources }) => ({\n ...result,\n score: rrf,\n metadata: {\n ...result.metadata,\n _sources: Array.from(sources),\n _rrfScore: rrf,\n },\n }))\n\n // Apply minimum score threshold\n if (config.minScore != null) {\n merged = merged.filter((r) => r.score >= config.minScore!)\n }\n\n // Sort by RRF score descending\n merged.sort((a, b) => b.score - a.score)\n\n return merged\n}\n\n/**\n * Simple deduplication without RRF scoring.\n * Keeps the highest-scored result for each entity+record pair.\n *\n * @param results - Array of search results\n * @returns Deduplicated results sorted by score\n */\nexport function deduplicateResults(results: SearchResult[]): SearchResult[] {\n const seen = new Map<string, SearchResult>()\n\n for (const result of results) {\n const key = `${result.entityId}:${result.recordId}`\n const existing = seen.get(key)\n\n if (!existing || result.score > existing.score) {\n seen.set(key, result)\n }\n }\n\n return Array.from(seen.values()).sort((a, b) => b.score - a.score)\n}\n\n/**\n * Normalize scores to 0-1 range using min-max normalization.\n * Useful when combining strategies with different score scales.\n *\n * @param results - Array of search results\n * @returns Results with normalized scores\n */\nexport function normalizeScores(results: SearchResult[]): SearchResult[] {\n if (results.length === 0) return []\n\n const scores = results.map((r) => r.score)\n const minScore = Math.min(...scores)\n const maxScore = Math.max(...scores)\n const range = maxScore - minScore\n\n if (range === 0) {\n // All scores are the same, normalize to 1.0\n return results.map((r) => ({ ...r, score: 1.0 }))\n }\n\n return results.map((r) => ({\n ...r,\n score: (r.score - minScore) / range,\n }))\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,7 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
5
  import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
6
+ import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
6
7
  import { resolveEmbeddingConfig } from "../../../lib/embedding-config.js";
7
8
  import { resolveGlobalSearchStrategies } from "../../../lib/global-search-config.js";
8
9
  import { searchError } from "../../../../../lib/debug.js";
@@ -72,10 +73,12 @@ async function GET(req) {
72
73
  limit
73
74
  });
74
75
  }
76
+ const scopeFilter = resolveOrganizationScopeFilter(scope, auth);
75
77
  const organizationId = typeof scope.selectedId === "string" && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : void 0;
76
78
  const searchOptions = {
77
79
  tenantId: auth.tenantId,
78
80
  organizationId,
81
+ organizationIds: scopeFilter.organizationIds,
79
82
  limit,
80
83
  strategies,
81
84
  entityTypes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/search/api/search/global/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { SearchService } from '@open-mercato/search'\nimport type { EmbeddingService } from '../../../../../vector'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'\nimport { searchError } from '../../../../../lib/debug'\nimport { globalSearchOpenApi } from '../../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\n/**\n * Global search endpoint for Cmd+K.\n * Always uses saved global search settings - does NOT accept strategies from URL.\n */\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Fetch saved global search strategies\n const strategies = await resolveGlobalSearchStrategies(container)\n\n // Load embedding config for vector strategy (only if vector is enabled)\n if (strategies.includes('vector')) {\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n strategiesEnabled: strategies,\n timing: 0,\n query,\n limit,\n })\n }\n\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n strategiesEnabled: strategies,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n searchError('search.api.global', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = globalSearchOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { EmbeddingService } from '../../../../../vector'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'\nimport { searchError } from '../../../../../lib/debug'\nimport { globalSearchOpenApi } from '../../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\n/**\n * Global search endpoint for Cmd+K.\n * Always uses saved global search settings - does NOT accept strategies from URL.\n */\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Fetch saved global search strategies\n const strategies = await resolveGlobalSearchStrategies(container)\n\n // Load embedding config for vector strategy (only if vector is enabled)\n if (strategies.includes('vector')) {\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n strategiesEnabled: strategies,\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n strategiesEnabled: strategies,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n searchError('search.api.global', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = globalSearchOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,7 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
5
  import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
6
+ import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
6
7
  import { resolveEmbeddingConfig } from "../../lib/embedding-config.js";
7
8
  import { searchError } from "../../../../lib/debug.js";
8
9
  import { searchOpenApi } from "../openapi.js";
@@ -73,10 +74,12 @@ async function GET(req) {
73
74
  limit
74
75
  });
75
76
  }
77
+ const scopeFilter = resolveOrganizationScopeFilter(scope, auth);
76
78
  const organizationId = typeof scope.selectedId === "string" && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : void 0;
77
79
  const searchOptions = {
78
80
  tenantId: auth.tenantId,
79
81
  organizationId,
82
+ organizationIds: scopeFilter.organizationIds,
80
83
  limit,
81
84
  strategies,
82
85
  entityTypes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/search/api/search/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { SearchService } from '@open-mercato/search'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { EmbeddingService } from '../../../../vector'\nimport { resolveEmbeddingConfig } from '../../lib/embedding-config'\nimport { searchError } from '../../../../lib/debug'\nimport { searchOpenApi } from '../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseStrategies(value: string | null): SearchStrategyId[] | undefined {\n if (!value) return undefined\n const strategies = value.split(',').map((s) => s.trim()).filter(Boolean) as SearchStrategyId[]\n return strategies.length > 0 ? strategies : undefined\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const strategies = parseStrategies(url.searchParams.get('strategies'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Load embedding config for vector strategy (same as Vector Search playground)\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n timing: 0,\n query,\n limit,\n })\n }\n\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.api.search', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n // Return generic message to client - don't expose internal error details\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = searchOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { EmbeddingService } from '../../../../vector'\nimport { resolveEmbeddingConfig } from '../../lib/embedding-config'\nimport { searchError } from '../../../../lib/debug'\nimport { searchOpenApi } from '../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseStrategies(value: string | null): SearchStrategyId[] | undefined {\n if (!value) return undefined\n const strategies = value.split(',').map((s) => s.trim()).filter(Boolean) as SearchStrategyId[]\n return strategies.length > 0 ? strategies : undefined\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const strategies = parseStrategies(url.searchParams.get('strategies'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Load embedding config for vector strategy (same as Vector Search playground)\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.api.search', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n // Return generic message to client - don't expose internal error details\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = searchOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/service.js CHANGED
@@ -5,6 +5,25 @@ const DEFAULT_MERGE_CONFIG = {
5
5
  duplicateHandling: "highest_score"
6
6
  };
7
7
  const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2e3;
8
+ function normalizeOrganizationFilter(options) {
9
+ const single = typeof options.organizationId === "string" ? options.organizationId.trim() : "";
10
+ if (single) return [single];
11
+ if (!Array.isArray(options.organizationIds)) return null;
12
+ const values = Array.from(new Set(
13
+ options.organizationIds.map((value) => typeof value === "string" ? value.trim() : "").filter((value) => value.length > 0)
14
+ ));
15
+ return values;
16
+ }
17
+ function filterResultsByOrganizationScope(results, options) {
18
+ const organizationIds = normalizeOrganizationFilter(options);
19
+ if (!organizationIds) return results;
20
+ if (organizationIds.length === 0) return [];
21
+ const allowed = new Set(organizationIds);
22
+ return results.filter((result) => {
23
+ const organizationId = typeof result.organizationId === "string" ? result.organizationId.trim() : "";
24
+ return organizationId.length > 0 && allowed.has(organizationId);
25
+ });
26
+ }
8
27
  class SearchService {
9
28
  constructor(options = {}) {
10
29
  this.availabilityCache = /* @__PURE__ */ new Map();
@@ -33,6 +52,10 @@ class SearchService {
33
52
  * @returns Merged and ranked search results
34
53
  */
35
54
  async search(query, options) {
55
+ const organizationIds = normalizeOrganizationFilter(options);
56
+ if (organizationIds && organizationIds.length === 0) {
57
+ return [];
58
+ }
36
59
  const strategyIds = options.strategies ?? this.defaultStrategies;
37
60
  const activeStrategies = await this.getAvailableStrategies(strategyIds);
38
61
  if (activeStrategies.length === 0) {
@@ -63,7 +86,8 @@ class SearchService {
63
86
  }
64
87
  }
65
88
  const merged = mergeAndRankResults(allResults, this.mergeConfig);
66
- return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId);
89
+ const scoped = filterResultsByOrganizationScope(merged, options);
90
+ return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId);
67
91
  }
68
92
  /**
69
93
  * Enrich results that are missing presenter data using the configured enricher.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/service.ts"],
4
- "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -13,9 +13,14 @@ class FullTextSearchStrategy {
13
13
  return this.driver.ensureReady();
14
14
  }
15
15
  async search(query, options) {
16
+ if (Array.isArray(options.organizationIds)) {
17
+ const organizationIds = options.organizationIds.map((value) => typeof value === "string" ? value.trim() : "").filter((value) => value.length > 0);
18
+ if (!options.organizationId && organizationIds.length === 0) return [];
19
+ }
16
20
  const hits = await this.driver.search(query, {
17
21
  tenantId: options.tenantId,
18
22
  organizationId: options.organizationId,
23
+ organizationIds: options.organizationIds,
19
24
  entityTypes: options.entityTypes,
20
25
  limit: options.limit,
21
26
  offset: options.offset
@@ -91,6 +96,7 @@ class FullTextSearchStrategy {
91
96
  recordId: hit.recordId,
92
97
  score: hit.score,
93
98
  source: this.id,
99
+ organizationId: hit.organizationId ?? null,
94
100
  presenter: hit.presenter,
95
101
  url: hit.url,
96
102
  links: hit.links,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/fulltext.strategy.ts"],
4
- "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type {\n FullTextSearchDriver,\n FullTextSearchDocument,\n FullTextSearchHit,\n DocumentLookupKey,\n IndexStats,\n} from '../fulltext/types'\n\n/**\n * FullTextSearchStrategy provides full-text fuzzy search using a pluggable driver.\n * Default driver is Meilisearch, but can be swapped for Algolia, Elasticsearch, etc.\n */\nexport class FullTextSearchStrategy implements SearchStrategy {\n readonly id: SearchStrategyId = 'fulltext'\n readonly name = 'Full-Text Search'\n readonly priority = 30 // Highest priority when available\n\n constructor(private readonly driver: FullTextSearchDriver) {}\n\n async isAvailable(): Promise<boolean> {\n return this.driver.isHealthy()\n }\n\n async ensureReady(): Promise<void> {\n return this.driver.ensureReady()\n }\n\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const hits = await this.driver.search(query, {\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n entityTypes: options.entityTypes,\n limit: options.limit,\n offset: options.offset,\n })\n\n return hits.map((hit) => this.mapHitToResult(hit))\n }\n\n async index(record: IndexableRecord): Promise<void> {\n const doc = this.mapRecordToDocument(record)\n await this.driver.index(doc)\n }\n\n async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {\n return this.driver.delete(recordId, tenantId)\n }\n\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (!this.driver.bulkIndex) {\n // Fallback to sequential indexing\n for (const record of records) {\n await this.index(record)\n }\n return\n }\n\n const docs = records.map((record) => this.mapRecordToDocument(record))\n return this.driver.bulkIndex(docs)\n }\n\n async purge(entityId: EntityId, tenantId: string): Promise<void> {\n if (!this.driver.purge) {\n return\n }\n return this.driver.purge(entityId, tenantId)\n }\n\n // Additional methods exposed for enrichment and admin purposes\n // These delegate to optional driver methods\n\n async clearIndex(tenantId: string): Promise<void> {\n if (!this.driver.clearIndex) {\n return\n }\n return this.driver.clearIndex(tenantId)\n }\n\n async recreateIndex(tenantId: string): Promise<void> {\n if (!this.driver.recreateIndex) {\n return\n }\n return this.driver.recreateIndex(tenantId)\n }\n\n async getDocuments(\n ids: DocumentLookupKey[],\n tenantId: string\n ): Promise<Map<string, SearchResult>> {\n if (!this.driver.getDocuments) {\n return new Map()\n }\n\n const hits = await this.driver.getDocuments(ids, tenantId)\n const result = new Map<string, SearchResult>()\n\n for (const [key, hit] of hits) {\n result.set(key, this.mapHitToResult(hit))\n }\n\n return result\n }\n\n async getIndexStats(tenantId: string): Promise<IndexStats | null> {\n if (!this.driver.getIndexStats) {\n return null\n }\n return this.driver.getIndexStats(tenantId)\n }\n\n async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {\n if (!this.driver.getEntityCounts) {\n return null\n }\n return this.driver.getEntityCounts(tenantId)\n }\n\n get driverId(): string {\n return this.driver.id\n }\n\n private mapHitToResult(hit: FullTextSearchHit): SearchResult {\n return {\n entityId: hit.entityId,\n recordId: hit.recordId,\n score: hit.score,\n source: this.id,\n presenter: hit.presenter,\n url: hit.url,\n links: hit.links,\n metadata: hit.metadata,\n }\n }\n\n private mapRecordToDocument(record: IndexableRecord): FullTextSearchDocument {\n return {\n recordId: record.recordId,\n entityId: record.entityId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n fields: record.fields,\n presenter: record.presenter,\n url: record.url,\n links: record.links,\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type {\n FullTextSearchDriver,\n FullTextSearchDocument,\n FullTextSearchHit,\n DocumentLookupKey,\n IndexStats,\n} from '../fulltext/types'\n\n/**\n * FullTextSearchStrategy provides full-text fuzzy search using a pluggable driver.\n * Default driver is Meilisearch, but can be swapped for Algolia, Elasticsearch, etc.\n */\nexport class FullTextSearchStrategy implements SearchStrategy {\n readonly id: SearchStrategyId = 'fulltext'\n readonly name = 'Full-Text Search'\n readonly priority = 30 // Highest priority when available\n\n constructor(private readonly driver: FullTextSearchDriver) {}\n\n async isAvailable(): Promise<boolean> {\n return this.driver.isHealthy()\n }\n\n async ensureReady(): Promise<void> {\n return this.driver.ensureReady()\n }\n\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n if (Array.isArray(options.organizationIds)) {\n const organizationIds = options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0)\n if (!options.organizationId && organizationIds.length === 0) return []\n }\n\n const hits = await this.driver.search(query, {\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n organizationIds: options.organizationIds,\n entityTypes: options.entityTypes,\n limit: options.limit,\n offset: options.offset,\n })\n\n return hits.map((hit) => this.mapHitToResult(hit))\n }\n\n async index(record: IndexableRecord): Promise<void> {\n const doc = this.mapRecordToDocument(record)\n await this.driver.index(doc)\n }\n\n async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {\n return this.driver.delete(recordId, tenantId)\n }\n\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (!this.driver.bulkIndex) {\n // Fallback to sequential indexing\n for (const record of records) {\n await this.index(record)\n }\n return\n }\n\n const docs = records.map((record) => this.mapRecordToDocument(record))\n return this.driver.bulkIndex(docs)\n }\n\n async purge(entityId: EntityId, tenantId: string): Promise<void> {\n if (!this.driver.purge) {\n return\n }\n return this.driver.purge(entityId, tenantId)\n }\n\n // Additional methods exposed for enrichment and admin purposes\n // These delegate to optional driver methods\n\n async clearIndex(tenantId: string): Promise<void> {\n if (!this.driver.clearIndex) {\n return\n }\n return this.driver.clearIndex(tenantId)\n }\n\n async recreateIndex(tenantId: string): Promise<void> {\n if (!this.driver.recreateIndex) {\n return\n }\n return this.driver.recreateIndex(tenantId)\n }\n\n async getDocuments(\n ids: DocumentLookupKey[],\n tenantId: string\n ): Promise<Map<string, SearchResult>> {\n if (!this.driver.getDocuments) {\n return new Map()\n }\n\n const hits = await this.driver.getDocuments(ids, tenantId)\n const result = new Map<string, SearchResult>()\n\n for (const [key, hit] of hits) {\n result.set(key, this.mapHitToResult(hit))\n }\n\n return result\n }\n\n async getIndexStats(tenantId: string): Promise<IndexStats | null> {\n if (!this.driver.getIndexStats) {\n return null\n }\n return this.driver.getIndexStats(tenantId)\n }\n\n async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {\n if (!this.driver.getEntityCounts) {\n return null\n }\n return this.driver.getEntityCounts(tenantId)\n }\n\n get driverId(): string {\n return this.driver.id\n }\n\n private mapHitToResult(hit: FullTextSearchHit): SearchResult {\n return {\n entityId: hit.entityId,\n recordId: hit.recordId,\n score: hit.score,\n source: this.id,\n organizationId: hit.organizationId ?? null,\n presenter: hit.presenter,\n url: hit.url,\n links: hit.links,\n metadata: hit.metadata,\n }\n }\n\n private mapRecordToDocument(record: IndexableRecord): FullTextSearchDocument {\n return {\n recordId: record.recordId,\n entityId: record.entityId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n fields: record.fields,\n presenter: record.presenter,\n url: record.url,\n links: record.links,\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }