@open-mercato/search 0.8.1-develop.7241.1.fad7b2ce2a → 0.8.1-develop.7254.1.ab24f26208

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.
@@ -293,6 +293,28 @@ function createMeilisearchDriver(options) {
293
293
  }
294
294
  return result;
295
295
  },
296
+ async listDocumentIds(entityId, tenantId, options2) {
297
+ const meiliClient = getClient();
298
+ const indexName = buildIndexName(tenantId);
299
+ const normalizedOrganizationId = typeof options2?.organizationId === "string" && options2.organizationId.trim().length > 0 ? options2.organizationId.trim() : null;
300
+ const filter = normalizedOrganizationId !== null ? `_entityId = "${escapeFilterValue(entityId)}" AND _organizationId = "${escapeFilterValue(normalizedOrganizationId)}"` : `_entityId = "${escapeFilterValue(entityId)}"`;
301
+ try {
302
+ const index = meiliClient.index(indexName);
303
+ const documents = await index.getDocuments({
304
+ filter,
305
+ fields: ["_id"],
306
+ limit: options2?.limit ?? defaultLimit,
307
+ offset: options2?.offset ?? 0
308
+ });
309
+ return documents.results.map((doc) => doc._id);
310
+ } catch (error) {
311
+ const meilisearchError = error;
312
+ if (meilisearchError.code === "index_not_found") {
313
+ return [];
314
+ }
315
+ throw error;
316
+ }
317
+ },
296
318
  async getIndexStats(tenantId) {
297
319
  const meiliClient = getClient();
298
320
  const indexName = buildIndexName(tenantId);
@@ -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 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, organizationId?: string | null): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n const filter =\n normalizedOrganizationId !== null\n ? `_entityId = \"${escapeFilterValue(entityId)}\" AND _organizationId = \"${escapeFilterValue(normalizedOrganizationId)}\"`\n : `_entityId = \"${escapeFilterValue(entityId)}\"`\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocuments({\n filter,\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 ListDocumentIdsOptions,\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, organizationId?: string | null): Promise<void> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n const filter =\n normalizedOrganizationId !== null\n ? `_entityId = \"${escapeFilterValue(entityId)}\" AND _organizationId = \"${escapeFilterValue(normalizedOrganizationId)}\"`\n : `_entityId = \"${escapeFilterValue(entityId)}\"`\n\n try {\n const index = meiliClient.index(indexName)\n await index.deleteDocuments({\n filter,\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 listDocumentIds(\n entityId: EntityId,\n tenantId: string,\n options?: ListDocumentIdsOptions\n ): Promise<string[]> {\n const meiliClient = getClient()\n const indexName = buildIndexName(tenantId)\n const normalizedOrganizationId =\n typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n const filter =\n normalizedOrganizationId !== null\n ? `_entityId = \"${escapeFilterValue(entityId)}\" AND _organizationId = \"${escapeFilterValue(normalizedOrganizationId)}\"`\n : `_entityId = \"${escapeFilterValue(entityId)}\"`\n\n try {\n const index = meiliClient.index(indexName)\n const documents = await index.getDocuments({\n filter,\n fields: ['_id'],\n limit: options?.limit ?? defaultLimit,\n offset: options?.offset ?? 0,\n })\n\n return documents.results.map((doc) => (doc as unknown as { _id: string })._id)\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 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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/fulltext/index.ts"],
4
- "sourcesContent": ["export type {\n FullTextSearchDriverId,\n FullTextSearchDocument,\n FullTextSearchQuery,\n FullTextSearchHit,\n DocumentLookupKey,\n IndexStats,\n FullTextSearchDriverConfig,\n FullTextSearchDriver,\n} from './types'\n\nexport { createMeilisearchDriver, type MeilisearchDriverOptions } from './drivers/meilisearch'\nexport { createFulltextDriver } from './drivers'\n"],
5
- "mappings": "AAWA,SAAS,+BAA8D;AACvE,SAAS,4BAA4B;",
4
+ "sourcesContent": ["export type {\n FullTextSearchDriverId,\n FullTextSearchDocument,\n FullTextSearchQuery,\n FullTextSearchHit,\n DocumentLookupKey,\n ListDocumentIdsOptions,\n IndexStats,\n FullTextSearchDriverConfig,\n FullTextSearchDriver,\n} from './types'\n\nexport { createMeilisearchDriver, type MeilisearchDriverOptions } from './drivers/meilisearch'\nexport { createFulltextDriver } from './drivers'\n"],
5
+ "mappings": "AAYA,SAAS,+BAA8D;AACvE,SAAS,4BAA4B;",
6
6
  "names": []
7
7
  }
@@ -87,6 +87,12 @@ class FullTextSearchStrategy {
87
87
  }
88
88
  return this.driver.getEntityCounts(tenantId);
89
89
  }
90
+ async listDocumentIds(entityId, tenantId, options) {
91
+ if (!this.driver.listDocumentIds) {
92
+ return null;
93
+ }
94
+ return this.driver.listDocumentIds(entityId, tenantId, options);
95
+ }
90
96
  get driverId() {
91
97
  return this.driver.id;
92
98
  }
@@ -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 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, organizationId?: string | null): Promise<void> {\n if (!this.driver.purge) {\n return\n }\n return this.driver.purge(entityId, tenantId, organizationId)\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": "AAoBO,MAAM,uBAAiD;AAAA;AAAA,EAK5D,YAA6B,QAA8B;AAA9B;AAJ7B,SAAS,KAAuB;AAChC,SAAS,OAAO;AAChB,SAAS,WAAW;AAAA,EAEwC;AAAA,EAE5D,MAAM,cAAgC;AACpC,WAAO,KAAK,OAAO,UAAU;AAAA,EAC/B;AAAA,EAEA,MAAM,cAA6B;AACjC,WAAO,KAAK,OAAO,YAAY;AAAA,EACjC;AAAA,EAEA,MAAM,OAAO,OAAe,SAAiD;AAC3E,QAAI,MAAM,QAAQ,QAAQ,eAAe,GAAG;AAC1C,YAAM,kBAAkB,QAAQ,gBAC7B,IAAI,CAAC,UAAW,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI,EAAG,EAC9D,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AACrC,UAAI,CAAC,QAAQ,kBAAkB,gBAAgB,WAAW,EAAG,QAAO,CAAC;AAAA,IACvE;AAEA,UAAM,OAAO,MAAM,KAAK,OAAO,OAAO,OAAO;AAAA,MAC3C,UAAU,QAAQ;AAAA,MAClB,gBAAgB,QAAQ;AAAA,MACxB,iBAAiB,QAAQ;AAAA,MACzB,aAAa,QAAQ;AAAA,MACrB,OAAO,QAAQ;AAAA,MACf,QAAQ,QAAQ;AAAA,IAClB,CAAC;AAED,WAAO,KAAK,IAAI,CAAC,QAAQ,KAAK,eAAe,GAAG,CAAC;AAAA,EACnD;AAAA,EAEA,MAAM,MAAM,QAAwC;AAClD,UAAM,MAAM,KAAK,oBAAoB,MAAM;AAC3C,UAAM,KAAK,OAAO,MAAM,GAAG;AAAA,EAC7B;AAAA,EAEA,MAAM,OAAO,UAAoB,UAAkB,UAAiC;AAClF,WAAO,KAAK,OAAO,OAAO,UAAU,QAAQ;AAAA,EAC9C;AAAA,EAEA,MAAM,UAAU,SAA2C;AACzD,QAAI,CAAC,KAAK,OAAO,WAAW;AAE1B,iBAAW,UAAU,SAAS;AAC5B,cAAM,KAAK,MAAM,MAAM;AAAA,MACzB;AACA;AAAA,IACF;AAEA,UAAM,OAAO,QAAQ,IAAI,CAAC,WAAW,KAAK,oBAAoB,MAAM,CAAC;AACrE,WAAO,KAAK,OAAO,UAAU,IAAI;AAAA,EACnC;AAAA,EAEA,MAAM,MAAM,UAAoB,UAAkB,gBAA+C;AAC/F,QAAI,CAAC,KAAK,OAAO,OAAO;AACtB;AAAA,IACF;AACA,WAAO,KAAK,OAAO,MAAM,UAAU,UAAU,cAAc;AAAA,EAC7D;AAAA;AAAA;AAAA,EAKA,MAAM,WAAW,UAAiC;AAChD,QAAI,CAAC,KAAK,OAAO,YAAY;AAC3B;AAAA,IACF;AACA,WAAO,KAAK,OAAO,WAAW,QAAQ;AAAA,EACxC;AAAA,EAEA,MAAM,cAAc,UAAiC;AACnD,QAAI,CAAC,KAAK,OAAO,eAAe;AAC9B;AAAA,IACF;AACA,WAAO,KAAK,OAAO,cAAc,QAAQ;AAAA,EAC3C;AAAA,EAEA,MAAM,aACJ,KACA,UACoC;AACpC,QAAI,CAAC,KAAK,OAAO,cAAc;AAC7B,aAAO,oBAAI,IAAI;AAAA,IACjB;AAEA,UAAM,OAAO,MAAM,KAAK,OAAO,aAAa,KAAK,QAAQ;AACzD,UAAM,SAAS,oBAAI,IAA0B;AAE7C,eAAW,CAAC,KAAK,GAAG,KAAK,MAAM;AAC7B,aAAO,IAAI,KAAK,KAAK,eAAe,GAAG,CAAC;AAAA,IAC1C;AAEA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,cAAc,UAA8C;AAChE,QAAI,CAAC,KAAK,OAAO,eAAe;AAC9B,aAAO;AAAA,IACT;AACA,WAAO,KAAK,OAAO,cAAc,QAAQ;AAAA,EAC3C;AAAA,EAEA,MAAM,gBAAgB,UAA0D;AAC9E,QAAI,CAAC,KAAK,OAAO,iBAAiB;AAChC,aAAO;AAAA,IACT;AACA,WAAO,KAAK,OAAO,gBAAgB,QAAQ;AAAA,EAC7C;AAAA,EAEA,IAAI,WAAmB;AACrB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EAEQ,eAAe,KAAsC;AAC3D,WAAO;AAAA,MACL,UAAU,IAAI;AAAA,MACd,UAAU,IAAI;AAAA,MACd,OAAO,IAAI;AAAA,MACX,QAAQ,KAAK;AAAA,MACb,gBAAgB,IAAI,kBAAkB;AAAA,MACtC,WAAW,IAAI;AAAA,MACf,KAAK,IAAI;AAAA,MACT,OAAO,IAAI;AAAA,MACX,UAAU,IAAI;AAAA,IAChB;AAAA,EACF;AAAA,EAEQ,oBAAoB,QAAiD;AAC3E,WAAO;AAAA,MACL,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO;AAAA,MACvB,QAAQ,OAAO;AAAA,MACf,WAAW,OAAO;AAAA,MAClB,KAAK,OAAO;AAAA,MACZ,OAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;",
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 ListDocumentIdsOptions,\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, organizationId?: string | null): Promise<void> {\n if (!this.driver.purge) {\n return\n }\n return this.driver.purge(entityId, tenantId, organizationId)\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 async listDocumentIds(\n entityId: EntityId,\n tenantId: string,\n options?: ListDocumentIdsOptions\n ): Promise<string[] | null> {\n if (!this.driver.listDocumentIds) {\n return null\n }\n return this.driver.listDocumentIds(entityId, tenantId, options)\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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.8.1-develop.7241.1.fad7b2ce2a",
3
+ "version": "0.8.1-develop.7254.1.ab24f26208",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.8.1-develop.7241.1.fad7b2ce2a",
131
- "@open-mercato/queue": "0.8.1-develop.7241.1.fad7b2ce2a",
132
- "@open-mercato/shared": "0.8.1-develop.7241.1.fad7b2ce2a"
130
+ "@open-mercato/core": "0.8.1-develop.7254.1.ab24f26208",
131
+ "@open-mercato/queue": "0.8.1-develop.7254.1.ab24f26208",
132
+ "@open-mercato/shared": "0.8.1-develop.7254.1.ab24f26208"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -0,0 +1,121 @@
1
+ import { FullTextSearchStrategy } from '../strategies/fulltext.strategy'
2
+
3
+ /**
4
+ * Coverage for issue #5931: the fulltext driver had no way to enumerate document
5
+ * IDs for an entity+tenant without Meilisearch's maxTotalHits search-hit cap.
6
+ * listDocumentIds uses index.getDocuments (not search), which is not subject to
7
+ * that cap.
8
+ */
9
+
10
+ describe('FullTextSearchStrategy.listDocumentIds', () => {
11
+ it('returns null when the driver does not implement listDocumentIds', async () => {
12
+ const driver = {
13
+ ensureReady: jest.fn().mockResolvedValue(undefined),
14
+ isHealthy: jest.fn().mockResolvedValue(true),
15
+ search: jest.fn().mockResolvedValue([]),
16
+ index: jest.fn().mockResolvedValue(undefined),
17
+ delete: jest.fn().mockResolvedValue(undefined),
18
+ }
19
+ const strategy = new FullTextSearchStrategy(driver as never)
20
+
21
+ const result = await strategy.listDocumentIds('demo:item', 'tenant-1')
22
+
23
+ expect(result).toBeNull()
24
+ })
25
+
26
+ it('forwards entityId, tenantId, and options to the driver and returns its result', async () => {
27
+ const driver = {
28
+ ensureReady: jest.fn().mockResolvedValue(undefined),
29
+ isHealthy: jest.fn().mockResolvedValue(true),
30
+ search: jest.fn().mockResolvedValue([]),
31
+ index: jest.fn().mockResolvedValue(undefined),
32
+ delete: jest.fn().mockResolvedValue(undefined),
33
+ listDocumentIds: jest.fn().mockResolvedValue(['rec-1', 'rec-2']),
34
+ }
35
+ const strategy = new FullTextSearchStrategy(driver as never)
36
+
37
+ const result = await strategy.listDocumentIds('demo:item', 'tenant-1', {
38
+ offset: 20,
39
+ limit: 20,
40
+ organizationId: 'org-A',
41
+ })
42
+
43
+ expect(driver.listDocumentIds).toHaveBeenCalledWith('demo:item', 'tenant-1', {
44
+ offset: 20,
45
+ limit: 20,
46
+ organizationId: 'org-A',
47
+ })
48
+ expect(result).toEqual(['rec-1', 'rec-2'])
49
+ })
50
+ })
51
+
52
+ describe('Meilisearch driver listDocumentIds', () => {
53
+ function createFakeMeilisearchModule(getDocumentsImpl: jest.Mock) {
54
+ return {
55
+ Meilisearch: jest.fn().mockImplementation(() => ({
56
+ index: jest.fn().mockReturnValue({ getDocuments: getDocumentsImpl }),
57
+ })),
58
+ }
59
+ }
60
+
61
+ async function loadDriverWithFakeClient(getDocumentsImpl: jest.Mock) {
62
+ jest.resetModules()
63
+ jest.doMock('meilisearch', () => createFakeMeilisearchModule(getDocumentsImpl))
64
+ const { createMeilisearchDriver } = await import('../fulltext/drivers/meilisearch')
65
+ return createMeilisearchDriver({ host: 'http://localhost:7700', indexPrefix: 'om' })
66
+ }
67
+
68
+ afterEach(() => {
69
+ jest.dontMock('meilisearch')
70
+ })
71
+
72
+ it('maps getDocuments results to a plain string array, filtered by entity only', async () => {
73
+ const getDocumentsImpl = jest.fn().mockResolvedValue({
74
+ results: [{ _id: 'rec-1' }, { _id: 'rec-2' }],
75
+ total: 2,
76
+ offset: 0,
77
+ limit: 20,
78
+ })
79
+ const driver = await loadDriverWithFakeClient(getDocumentsImpl)
80
+
81
+ const ids = await driver.listDocumentIds!('demo:item', 'tenant-1')
82
+
83
+ expect(ids).toEqual(['rec-1', 'rec-2'])
84
+ expect(getDocumentsImpl).toHaveBeenCalledWith({
85
+ filter: '_entityId = "demo:item"',
86
+ fields: ['_id'],
87
+ limit: 20,
88
+ offset: 0,
89
+ })
90
+ })
91
+
92
+ it('adds an _organizationId clause to the filter when organizationId is provided', async () => {
93
+ const getDocumentsImpl = jest.fn().mockResolvedValue({ results: [], total: 0, offset: 0, limit: 10 })
94
+ const driver = await loadDriverWithFakeClient(getDocumentsImpl)
95
+
96
+ await driver.listDocumentIds!('demo:item', 'tenant-1', { offset: 40, limit: 10, organizationId: 'org-A' })
97
+
98
+ expect(getDocumentsImpl).toHaveBeenCalledWith({
99
+ filter: '_entityId = "demo:item" AND _organizationId = "org-A"',
100
+ fields: ['_id'],
101
+ limit: 10,
102
+ offset: 40,
103
+ })
104
+ })
105
+
106
+ it('returns an empty array when the index does not exist yet', async () => {
107
+ const getDocumentsImpl = jest.fn().mockRejectedValue({ code: 'index_not_found' })
108
+ const driver = await loadDriverWithFakeClient(getDocumentsImpl)
109
+
110
+ const ids = await driver.listDocumentIds!('demo:item', 'tenant-1')
111
+
112
+ expect(ids).toEqual([])
113
+ })
114
+
115
+ it('propagates errors other than index_not_found', async () => {
116
+ const getDocumentsImpl = jest.fn().mockRejectedValue({ code: 'internal_error' })
117
+ const driver = await loadDriverWithFakeClient(getDocumentsImpl)
118
+
119
+ await expect(driver.listDocumentIds!('demo:item', 'tenant-1')).rejects.toEqual({ code: 'internal_error' })
120
+ })
121
+ })
@@ -8,6 +8,7 @@ import type {
8
8
  FullTextSearchQuery,
9
9
  FullTextSearchHit,
10
10
  DocumentLookupKey,
11
+ ListDocumentIdsOptions,
11
12
  IndexStats,
12
13
  } from '../../types'
13
14
  import { extractSearchableFields, type EncryptionMapEntry } from '../../../lib/field-policy'
@@ -392,6 +393,41 @@ export function createMeilisearchDriver(
392
393
  return result
393
394
  },
394
395
 
396
+ async listDocumentIds(
397
+ entityId: EntityId,
398
+ tenantId: string,
399
+ options?: ListDocumentIdsOptions
400
+ ): Promise<string[]> {
401
+ const meiliClient = getClient()
402
+ const indexName = buildIndexName(tenantId)
403
+ const normalizedOrganizationId =
404
+ typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0
405
+ ? options.organizationId.trim()
406
+ : null
407
+ const filter =
408
+ normalizedOrganizationId !== null
409
+ ? `_entityId = "${escapeFilterValue(entityId)}" AND _organizationId = "${escapeFilterValue(normalizedOrganizationId)}"`
410
+ : `_entityId = "${escapeFilterValue(entityId)}"`
411
+
412
+ try {
413
+ const index = meiliClient.index(indexName)
414
+ const documents = await index.getDocuments({
415
+ filter,
416
+ fields: ['_id'],
417
+ limit: options?.limit ?? defaultLimit,
418
+ offset: options?.offset ?? 0,
419
+ })
420
+
421
+ return documents.results.map((doc) => (doc as unknown as { _id: string })._id)
422
+ } catch (error: unknown) {
423
+ const meilisearchError = error as { code?: string }
424
+ if (meilisearchError.code === 'index_not_found') {
425
+ return []
426
+ }
427
+ throw error
428
+ }
429
+ },
430
+
395
431
  async getIndexStats(tenantId: string): Promise<IndexStats | null> {
396
432
  const meiliClient = getClient()
397
433
  const indexName = buildIndexName(tenantId)
@@ -4,6 +4,7 @@ export type {
4
4
  FullTextSearchQuery,
5
5
  FullTextSearchHit,
6
6
  DocumentLookupKey,
7
+ ListDocumentIdsOptions,
7
8
  IndexStats,
8
9
  FullTextSearchDriverConfig,
9
10
  FullTextSearchDriver,
@@ -65,6 +65,12 @@ export type DocumentLookupKey = {
65
65
  recordId: string
66
66
  }
67
67
 
68
+ export type ListDocumentIdsOptions = {
69
+ offset?: number
70
+ limit?: number
71
+ organizationId?: string | null
72
+ }
73
+
68
74
  export type IndexStats = {
69
75
  numberOfDocuments: number
70
76
  isIndexing: boolean
@@ -111,6 +117,15 @@ export interface FullTextSearchDriver {
111
117
  tenantId: string
112
118
  ): Promise<Map<string, FullTextSearchHit>>
113
119
 
120
+ // Uncapped ID enumeration for reconciliation/retention (optional) — unlike
121
+ // search(), this is not subject to the backend's ranked-search hit cap
122
+ // (e.g. Meilisearch's maxTotalHits)
123
+ listDocumentIds?(
124
+ entityId: EntityId,
125
+ tenantId: string,
126
+ options?: ListDocumentIdsOptions
127
+ ): Promise<string[]>
128
+
114
129
  // Stats/admin (optional)
115
130
  getIndexStats?(tenantId: string): Promise<IndexStats | null>
116
131
  getEntityCounts?(tenantId: string): Promise<Record<string, number> | null>
@@ -11,6 +11,7 @@ import type {
11
11
  FullTextSearchDocument,
12
12
  FullTextSearchHit,
13
13
  DocumentLookupKey,
14
+ ListDocumentIdsOptions,
14
15
  IndexStats,
15
16
  } from '../fulltext/types'
16
17
 
@@ -131,6 +132,17 @@ export class FullTextSearchStrategy implements SearchStrategy {
131
132
  return this.driver.getEntityCounts(tenantId)
132
133
  }
133
134
 
135
+ async listDocumentIds(
136
+ entityId: EntityId,
137
+ tenantId: string,
138
+ options?: ListDocumentIdsOptions
139
+ ): Promise<string[] | null> {
140
+ if (!this.driver.listDocumentIds) {
141
+ return null
142
+ }
143
+ return this.driver.listDocumentIds(entityId, tenantId, options)
144
+ }
145
+
134
146
  get driverId(): string {
135
147
  return this.driver.id
136
148
  }