@murumets-ee/search-elasticsearch 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/client.ts","../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"mappings":"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
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/client.ts","../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{SearchError as e,SearchErrorCodes as t}from"@murumets-ee/search";const n={fullText:!0,ranking:!0,facets:!0,fuzzy:!1,prefix:!0};var r=class{resource;permissionResource;capabilities;client;indexAlias;searchableFields;filterableFieldSet;facetFields;facetSize;transform;constructor(e){if(e.searchableFields.length===0)throw Error(`ElasticsearchProvider '${e.resource}' requires at least one searchable field`);let t=e.filterableFields??e.searchableFields,r=e.facetFields??[];for(let n of r)if(!t.includes(n))throw Error(`ElasticsearchProvider '${e.resource}' facet field '${n}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`);this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.capabilities={...n,...e.capabilities??{}},this.client=e.client,this.indexAlias=e.indexAlias,this.searchableFields=e.searchableFields,this.filterableFieldSet=new Set(t),this.facetFields=r,this.facetSize=e.facetSize??50,this.transform=e.transform}async search(n,r){if(r.aborted)throw new e(`Search aborted`,504,t.Timeout);let i={index:this.indexAlias,query:this.buildQuery(n),size:n.limit,from:n.offset,track_total_hits:!0};this.facetFields.length>0&&(i.aggs=this.buildAggs());let a=Date.now(),s;try{s=await this.client.search(i,{signal:r})}catch(n){throw r.aborted?new e(`Search aborted`,504,t.Timeout,{cause:n}):n}let c=Date.now()-a,l=o(s.hits.total),u=[];for(let e of s.hits.hits)e._id===void 0||e._source===void 0||u.push(this.transform(e._source,e._score??null,e._id));return{rows:u,total:l,facets:this.collectFacets(s.aggregations??{}),durationMs:c}}buildQuery(e){let t=[this.buildQueryClause(e)],n=this.buildFilterClauses(e.filters);return{bool:{must:t,...n.length>0&&{filter:n}}}}buildQueryClause(e){let t=i[e.mode];return a(this.searchableFields,n=>({[t]:{[n]:e.query}}))}buildFilterClauses(e){let t=[];for(let[n,r]of Object.entries(e))if(this.filterableFieldSet.has(n)&&r.length!==0)if(r.length===1){let e=r[0];if(e===void 0)continue;t.push({term:{[n]:e}})}else t.push({terms:{[n]:[...r]}});return t}buildAggs(){let e={};for(let t of this.facetFields)e[t]={terms:{field:t,size:this.facetSize}};return e}collectFacets(e){let t=[];for(let n of this.facetFields){let r=e[n];if(!r)continue;let i=r.buckets;if(!Array.isArray(i))continue;let a=i.map(e=>{let t=e;return{value:String(t.key),count:t.doc_count}});t.push({field:n,buckets:a})}return t}};const i={term:`term`,prefix:`prefix`,phrase:`match_phrase`};function a(e,t){let n=e[0];if(n===void 0)throw Error(`unreachable: searchableFields cannot be empty (constructor enforces)`);return e.length===1?t(n):{bool:{should:e.map(t),minimum_should_match:1}}}function o(e){return e===void 0?0:typeof e==`number`?e:e.value}const s=`parts`,c={settings:{number_of_shards:3,number_of_replicas:1,max_result_window:1e5,refresh_interval:`30s`},mappings:{properties:{doc_id:{type:`keyword`},code:{type:`keyword`},code_normalized:{type:`keyword`},brand_id:{type:`keyword`},brand_slug:{type:`keyword`},supplier_id:{type:`keyword`},supplier_display_name:{type:`keyword`},net_price_eur:{type:`float`},gross_price_eur:{type:`float`},currency:{type:`keyword`},barcode:{type:`keyword`},name_de:{type:`text`,index:!1},name_en:{type:`text`,index:!1},name_es:{type:`text`,index:!1},name_fr:{type:`text`,index:!1},name_it:{type:`text`,index:!1},name_nl:{type:`text`,index:!1},name_pt:{type:`text`,index:!1},description1:{type:`text`,index:!1},description2:{type:`text`,index:!1},import_batch_id:{type:`keyword`},imported_at:{type:`date`}}}};async function l(e,t){let{name:n,config:r}=t;await e.indices.create({index:n,...r.settings!==void 0&&{settings:r.settings},...r.mappings!==void 0&&{mappings:r.mappings}})}async function u(e,t){let{alias:n,toIndex:r,fromIndex:i}=t,a=[];i!==void 0&&i!==r&&a.push({remove:{index:i,alias:n}}),a.push({add:{index:r,alias:n}}),await e.indices.updateAliases({actions:a})}async function d(e,t){await e.indices.delete({index:t})}async function f(e,t){let{alias:n,config:r}=t,i=await p(e,n);if(i!==null)return i;let a=`${n}_v1`;return await e.indices.exists({index:a})||await l(e,{name:a,config:r}),await u(e,{alias:n,toIndex:a}),a}async function p(e,t){try{let n=await e.indices.getAlias({name:t}),r=Object.keys(n);return r.length===0?null:[...r].sort()[r.length-1]??null}catch(e){if(m(e))return null;throw e}}function m(e){if(typeof e!=`object`||!e)return!1;let t=e;if(t.meta?.statusCode!==404)return!1;let n=t.body?.error
|
|
1
|
+
import{SearchError as e,SearchErrorCodes as t}from"@murumets-ee/search";const n={fullText:!0,ranking:!0,facets:!0,fuzzy:!1,prefix:!0};var r=class{resource;permissionResource;capabilities;client;indexAlias;searchableFields;filterableFieldSet;facetFields;facetSize;transform;constructor(e){if(e.searchableFields.length===0)throw Error(`ElasticsearchProvider '${e.resource}' requires at least one searchable field`);let t=e.filterableFields??e.searchableFields,r=e.facetFields??[];for(let n of r)if(!t.includes(n))throw Error(`ElasticsearchProvider '${e.resource}' facet field '${n}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`);this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.capabilities={...n,...e.capabilities??{}},this.client=e.client,this.indexAlias=e.indexAlias,this.searchableFields=e.searchableFields,this.filterableFieldSet=new Set(t),this.facetFields=r,this.facetSize=e.facetSize??50,this.transform=e.transform}async search(n,r){if(r.aborted)throw new e(`Search aborted`,504,t.Timeout);let i={index:this.indexAlias,query:this.buildQuery(n),size:n.limit,from:n.offset,track_total_hits:!0};this.facetFields.length>0&&(i.aggs=this.buildAggs());let a=Date.now(),s;try{s=await this.client.search(i,{signal:r})}catch(n){throw r.aborted?new e(`Search aborted`,504,t.Timeout,{cause:n}):n}let c=Date.now()-a,l=o(s.hits.total),u=[];for(let e of s.hits.hits)e._id===void 0||e._source===void 0||u.push(this.transform(e._source,e._score??null,e._id));return{rows:u,total:l,facets:this.collectFacets(s.aggregations??{}),durationMs:c}}buildQuery(e){let t=[this.buildQueryClause(e)],n=this.buildFilterClauses(e.filters);return{bool:{must:t,...n.length>0&&{filter:n}}}}buildQueryClause(e){let t=i[e.mode];return a(this.searchableFields,n=>({[t]:{[n]:e.query}}))}buildFilterClauses(e){let t=[];for(let[n,r]of Object.entries(e))if(this.filterableFieldSet.has(n)&&r.length!==0)if(r.length===1){let e=r[0];if(e===void 0)continue;t.push({term:{[n]:e}})}else t.push({terms:{[n]:[...r]}});return t}buildAggs(){let e={};for(let t of this.facetFields)e[t]={terms:{field:t,size:this.facetSize}};return e}collectFacets(e){let t=[];for(let n of this.facetFields){let r=e[n];if(!r)continue;let i=r.buckets;if(!Array.isArray(i))continue;let a=i.map(e=>{let t=e;return{value:String(t.key),count:t.doc_count}});t.push({field:n,buckets:a})}return t}};const i={term:`term`,prefix:`prefix`,phrase:`match_phrase`};function a(e,t){let n=e[0];if(n===void 0)throw Error(`unreachable: searchableFields cannot be empty (constructor enforces)`);return e.length===1?t(n):{bool:{should:e.map(t),minimum_should_match:1}}}function o(e){return e===void 0?0:typeof e==`number`?e:e.value}const s=`parts`,c={settings:{number_of_shards:3,number_of_replicas:1,max_result_window:1e5,refresh_interval:`30s`},mappings:{properties:{doc_id:{type:`keyword`},code:{type:`keyword`},code_normalized:{type:`keyword`},brand_id:{type:`keyword`},brand_slug:{type:`keyword`},supplier_id:{type:`keyword`},supplier_display_name:{type:`keyword`},net_price_eur:{type:`float`},gross_price_eur:{type:`float`},currency:{type:`keyword`},barcode:{type:`keyword`},name_de:{type:`text`,index:!1},name_en:{type:`text`,index:!1},name_es:{type:`text`,index:!1},name_fr:{type:`text`,index:!1},name_it:{type:`text`,index:!1},name_nl:{type:`text`,index:!1},name_pt:{type:`text`,index:!1},description1:{type:`text`,index:!1},description2:{type:`text`,index:!1},import_batch_id:{type:`keyword`},imported_at:{type:`date`}}}};async function l(e,t){let{name:n,config:r}=t;await e.indices.create({index:n,...r.settings!==void 0&&{settings:r.settings},...r.mappings!==void 0&&{mappings:r.mappings}})}async function u(e,t){let{alias:n,toIndex:r,fromIndex:i}=t,a=[];i!==void 0&&i!==r&&a.push({remove:{index:i,alias:n}}),a.push({add:{index:r,alias:n}}),await e.indices.updateAliases({actions:a})}async function d(e,t){await e.indices.delete({index:t})}async function f(e,t){let{alias:n,config:r}=t,i=await p(e,n);if(i!==null)return i;if(await e.indices.exists({index:n}))throw Error(`ensureAliasedIndex: a physical index named "${n}" already exists with no alias attached. This usually means a worker bulk-indexed before the alias bootstrap ran, so ES auto-created the index with text-typed string fields (breaking facets / aggregations). Drop the index (\`curl -XDELETE http://<es>/${n}\`) and re-run; subsequent imports will land in "${n}_v1" with the intended mapping.`);let a=`${n}_v1`;return await e.indices.exists({index:a})||await l(e,{name:a,config:r}),await u(e,{alias:n,toIndex:a}),a}async function p(e,t){try{let n=await e.indices.getAlias({name:t}),r=Object.keys(n);return r.length===0?null:[...r].sort()[r.length-1]??null}catch(e){if(m(e))return null;throw e}}function m(e){if(typeof e!=`object`||!e)return!1;let t=e;if(t.meta?.statusCode!==404)return!1;let n=t.body?.error;return typeof n==`string`?/alias.*missing|alias_not_found|index_not_found/i.test(n):n===void 0?!1:n.type===`index_not_found_exception`||n.type===`alias_not_found_exception`}async function h(e,t){let{index:n,docs:r,refresh:i,signal:a}=t;if(r.length===0)return{took:0,submitted:0,succeeded:0,failures:[]};let o=[];for(let{id:e,doc:t}of r)o.push({index:{_index:n,_id:e}}),o.push(t);let s=await e.bulk({operations:o,...i!==void 0&&{refresh:i}},...a===void 0?[]:[{signal:a}]);if(!s.errors)return{took:s.took,submitted:r.length,succeeded:r.length,failures:[]};let c=[];for(let e of s.items){let t=e.index;!t||t.error===void 0||c.push({id:t._id??`<unknown>`,type:t.error.type,reason:t.error.reason??`<no reason>`,status:t.status})}return{took:s.took,submitted:r.length,succeeded:r.length-c.length,failures:c}}async function g(e){throw Error(`reindex() is not implemented — deferred per PLAN-ECOMMERCE.md §6 PoC track. The PoC import flow writes through bulkUpsert into the live aliased index. When mapping-changing reindex is needed, finish this implementation per the JSDoc on this function.`)}export{r as ElasticsearchProvider,s as PARTS_INDEX_ALIAS,h as bulkUpsert,l as createIndex,d as dropIndex,f as ensureAliasedIndex,c as partsIndexConfig,p as readAliasIndex,g as reindex,u as swapAlias};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["typedBuckets"],"sources":["../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"sourcesContent":["/**\n * `ElasticsearchProvider` — implements the generic SearchProvider contract\n * from `@murumets-ee/search`, backed by an ES client (or anything matching\n * the `EsClientLike` structural interface).\n *\n * Per D5 in PLAN-ECOMMERCE.md the parts use case is term + prefix only —\n * but this class is generic over any ES use case, so it implements all\n * three modes (`term`, `prefix`, `phrase`). Consumers gate UI behavior via\n * the configurable `capabilities` flag.\n *\n * Per D14 the consumer supplies the `transform` projection from raw doc\n * → SearchResultRow; per D15 facets are subject to the same WHERE filter as\n * the underlying query (we just compute aggs in the same search call, so\n * the row-level scoping applies automatically).\n *\n * Per the binding constraint on `FacetFilters` (PR 1): field names are\n * arbitrary user strings — the `filterableFields` whitelist is the only\n * line of defense against ES query injection. The constructor throws when\n * the whitelist is empty AND filters were configured to be acceptable.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport type {\n FacetAggregation,\n FacetBucket,\n FacetFilters,\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport type { EsClientLike, SearchRequest, SearchResponse } from './client.js'\n\nexport interface ElasticsearchProviderConfig<TDoc> {\n /** Logical resource name (registry key, route segment, audit tag). */\n resource: string\n /**\n * Permission resource for the framework `view` check. Defaults to\n * `resource`. Override when the search resource and the entity-level\n * permission diverge (e.g. resource `parts` gated on `commerce.parts:view`).\n */\n permissionResource?: string\n /** ES client (or structural equivalent for tests). */\n client: EsClientLike\n /** Alias to query — the alias-versioned indirection from D6. */\n indexAlias: string\n /**\n * Whitelist of fields used for `term` / `prefix` matching. Each entry\n * MUST be a `keyword`-typed field on the index mapping; ES `term` and\n * `prefix` queries on `text` fields silently miss what they look like\n * they should match.\n *\n * The first field is the primary; for `term` mode, all listed fields\n * are queried with OR semantics.\n */\n searchableFields: readonly string[]\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`\n * (`?f.<field>=<value>`). Defaults to `searchableFields`. Anything\n * outside this list is dropped — the single line of defense against the\n * arbitrary-field-name injection vector documented on `FacetFilters`.\n */\n filterableFields?: readonly string[]\n /**\n * Whitelist of fields to compute facet buckets for. Subset of\n * `filterableFields`. ES applies the same WHERE filter as the search\n * query, so facet counts respect row-level scoping (D15).\n */\n facetFields?: readonly string[]\n /** Per-facet bucket cap. Default 50 — matches typical UI panel size. */\n facetSize?: number\n /**\n * Override capabilities. Defaults: `{fullText:true, ranking:true,\n * facets:true, fuzzy:false, prefix:true}`. Per D5 the parts consumer\n * declares `{fullText:false, ranking:false, fuzzy:false}` so the search\n * box doesn't render full-text affordances.\n */\n capabilities?: Partial<SearchCapabilities>\n /** Project an ES hit into a SearchResultRow (D14 — required, no default). */\n transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n}\n\nconst DEFAULT_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: true,\n fuzzy: false,\n prefix: true,\n}\n\nconst DEFAULT_FACET_SIZE = 50\n\nexport class ElasticsearchProvider<TDoc = Record<string, unknown>>\n implements SearchProvider\n{\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities: SearchCapabilities\n\n private readonly client: EsClientLike\n private readonly indexAlias: string\n private readonly searchableFields: readonly string[]\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly facetFields: readonly string[]\n private readonly facetSize: number\n private readonly transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n\n constructor(config: ElasticsearchProviderConfig<TDoc>) {\n if (config.searchableFields.length === 0) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' requires at least one searchable field`,\n )\n }\n const filterable = config.filterableFields ?? config.searchableFields\n const facets = config.facetFields ?? []\n for (const facet of facets) {\n if (!filterable.includes(facet)) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' facet field '${facet}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`,\n )\n }\n }\n\n this.resource = config.resource\n if (config.permissionResource !== undefined) {\n this.permissionResource = config.permissionResource\n }\n this.capabilities = { ...DEFAULT_CAPABILITIES, ...(config.capabilities ?? {}) }\n this.client = config.client\n this.indexAlias = config.indexAlias\n this.searchableFields = config.searchableFields\n this.filterableFieldSet = new Set(filterable)\n this.facetFields = facets\n this.facetSize = config.facetSize ?? DEFAULT_FACET_SIZE\n this.transform = config.transform\n }\n\n async search(input: SearchInput, signal: AbortSignal): Promise<SearchResult> {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n\n const request: SearchRequest = {\n index: this.indexAlias,\n query: this.buildQuery(input),\n size: input.limit,\n from: input.offset,\n // ES 8+ caps total at 10000 by default — opt into exact counts so\n // the displayed total isn't a ceiling masquerading as truth.\n track_total_hits: true,\n }\n if (this.facetFields.length > 0) {\n request.aggs = this.buildAggs()\n }\n\n const start = Date.now()\n let response: SearchResponse<TDoc>\n try {\n // signal goes into the SECOND options arg — the real ES v8 client\n // ignores `signal` placed on the request params (see RequestOptions\n // JSDoc on client.ts).\n response = await this.client.search<TDoc>(request, { signal })\n } catch (error: unknown) {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout, { cause: error })\n }\n throw error\n }\n const durationMs = Date.now() - start\n\n const total = readTotal(response.hits.total)\n\n // Real ES `SearchHit` has `_id?: Id`, `_source?: TDocument`, `_score?:\n // number | null` — all optional. Skip hits missing the fields we need\n // rather than crashing the whole search; the missing-field shapes are\n // rare (require explicit `_source: false` or specific PIT/scroll\n // responses) but the type system surfaces them honestly now.\n const rows: SearchResultRow[] = []\n for (const hit of response.hits.hits) {\n if (hit._id === undefined || hit._source === undefined) continue\n rows.push(this.transform(hit._source, hit._score ?? null, hit._id))\n }\n\n return {\n rows,\n total,\n facets: this.collectFacets(response.aggregations ?? {}),\n durationMs,\n }\n }\n\n /**\n * Compose the ES query body. Combines the user query (mode-dependent)\n * with the filter WHERE (whitelist-gated) under a single `bool.filter`\n * — `filter` instead of `must` so docs aren't scored by the filters\n * (faster, cacheable). The text query goes in `must` so scoring still\n * applies to phrase mode.\n *\n * `searchableFields` is non-empty by construction (constructor throws\n * on empty input), so `buildQueryClause` always returns a clause —\n * `must.push` runs unconditionally.\n */\n private buildQuery(input: SearchInput): estypes.QueryDslQueryContainer {\n const must: estypes.QueryDslQueryContainer[] = [this.buildQueryClause(input)]\n const filter = this.buildFilterClauses(input.filters)\n return {\n bool: {\n must,\n ...(filter.length > 0 && { filter }),\n },\n }\n }\n\n private buildQueryClause(input: SearchInput): estypes.QueryDslQueryContainer {\n const verb = QUERY_VERB_BY_MODE[input.mode]\n return wrapShouldOr(this.searchableFields, (field) => ({\n [verb]: { [field]: input.query },\n }))\n }\n\n /**\n * Build `terms`-clause filters from caller-supplied `filters`. Field\n * names not in the whitelist are silently dropped; multi-value entries\n * use ES `terms` (OR within field), single values use `term`. The\n * caller-facing semantic (\"this field=these values\") matches IlikeProvider.\n */\n private buildFilterClauses(filters: FacetFilters): estypes.QueryDslQueryContainer[] {\n const out: estypes.QueryDslQueryContainer[] = []\n for (const [field, values] of Object.entries(filters)) {\n if (!this.filterableFieldSet.has(field)) continue\n if (values.length === 0) continue\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) continue\n out.push({ term: { [field]: single } })\n } else {\n out.push({ terms: { [field]: [...values] } })\n }\n }\n return out\n }\n\n private buildAggs(): Record<string, estypes.AggregationsAggregationContainer> {\n const aggs: Record<string, estypes.AggregationsAggregationContainer> = {}\n for (const field of this.facetFields) {\n aggs[field] = { terms: { field, size: this.facetSize } }\n }\n return aggs\n }\n\n private collectFacets(\n aggregations: Record<string, estypes.AggregationsAggregate>,\n ): readonly FacetAggregation[] {\n const out: FacetAggregation[] = []\n for (const field of this.facetFields) {\n const agg = aggregations[field]\n if (!agg) continue\n // We only emit terms aggregations (see buildAggs), which always\n // return array-form buckets. `AggregationsAggregate` is a union of\n // ~70 aggregate shapes; structurally probe for the bucket array\n // rather than narrowing the entire union. ES's `AggregationsBuckets`\n // can also be `Record<string, T>` (filter-style aggs) — those are\n // config drift here, ignore rather than misrepresent.\n const rawBuckets = (agg as { buckets?: unknown }).buckets\n if (!Array.isArray(rawBuckets)) continue\n const typedBuckets = rawBuckets as ReadonlyArray<unknown>\n const buckets: FacetBucket[] = typedBuckets.map((b) => {\n // ES `FieldValue` officially permits `any`; in practice a terms\n // aggregation only emits `string | number | boolean`. Narrow to\n // that here so the cast doesn't smuggle `any` through (and the\n // package's 100% type-coverage floor stays a real signal).\n const bucket = b as { key: string | number | boolean; doc_count: number }\n return {\n // Coerce so the FacetBucket.value: string contract holds.\n value: String(bucket.key),\n count: bucket.doc_count,\n }\n })\n out.push({ field, buckets })\n }\n return out\n }\n}\n\nconst QUERY_VERB_BY_MODE: Record<SearchInput['mode'], 'term' | 'prefix' | 'match_phrase'> = {\n term: 'term',\n prefix: 'prefix',\n phrase: 'match_phrase',\n}\n\n/**\n * Build a single-clause query (when `fields` has one entry) or a\n * `bool.should` OR over N field-clauses with `minimum_should_match: 1`.\n * Caller passes a per-field clause builder; this collapses the\n * single-vs-multi shape repetition that used to live in three branches.\n */\nfunction wrapShouldOr(\n fields: readonly string[],\n perField: (field: string) => estypes.QueryDslQueryContainer,\n): estypes.QueryDslQueryContainer {\n // Constructor enforces `fields.length >= 1`, so `fields[0]` is always\n // defined. Throw on the impossible-state path so a future regression\n // lands loudly rather than producing a silent `match_all`.\n const onlyField = fields[0]\n if (onlyField === undefined) {\n throw new Error('unreachable: searchableFields cannot be empty (constructor enforces)')\n }\n if (fields.length === 1) return perField(onlyField)\n return {\n bool: {\n should: fields.map(perField),\n minimum_should_match: 1,\n },\n }\n}\n\n/**\n * ES total can be `{ value, relation } | number | undefined`. The\n * `undefined` shape is rare (some scroll/PIT responses) but real — so\n * treat it as `0` rather than reading `.value` on undefined and\n * crashing.\n */\nfunction readTotal(total: SearchResponse<unknown>['hits']['total']): number {\n if (total === undefined) return 0\n if (typeof total === 'number') return total\n return total.value\n}\n","/**\n * Parts catalog mapping.\n *\n * Per D4 in PLAN-ECOMMERCE.md: one ES doc per `(code_normalized, supplier_id)`,\n * `_id = \"<code>__<supplier_id>\"`. Consumers read this via the alias\n * (`PARTS_INDEX_ALIAS`) and never address a physical index name directly so\n * a mapping change can be deployed via D6 alias-versioned reindex.\n *\n * Per D5: search modes are `term` (exact-equality on `code_normalized`) and\n * `prefix` (starts-with on `code_normalized`). No fuzzy / wildcard / multi-\n * field boost. Description fields are stored, not indexed for full-text.\n *\n * The mapping is hardcoded for the PoC (per the §6 PoC track scope) — when\n * a non-parts ES use case arrives, factor out a shared helper. Until then,\n * one mapping shape avoids the abstraction cost of a generic mapping\n * registry that has zero second consumer to validate it against.\n */\n\n/** Stable alias every reader queries through. Physical indices are versioned. */\nexport const PARTS_INDEX_ALIAS = 'parts'\n\n/** Document shape written by the importer; read by ElasticsearchProvider. */\nexport interface PartsDocument {\n /** `<code_normalized>__<supplier_id>` — also the ES `_id`. */\n doc_id: string\n /** Code as written by the supplier (`ME-A0000000000`). Display-only. */\n code: string\n /** Bare manufacturer code, uppercased + dash/space-stripped. Used for term + prefix. */\n code_normalized: string\n /** Brand UUID — keyed off `brand.id` per D24 (typed FK, not a string). */\n brand_id: string\n /** Brand slug — used as the facet bucket label and for human-readable filters. */\n brand_slug: string\n /** Supplier UUID. */\n supplier_id: string\n /** Supplier customer-facing alias (D24 anti-disintermediation). NEVER `supplier.name`. */\n supplier_display_name: string\n /** Net price in EUR (D9 single-currency). */\n net_price_eur: number\n /** Gross price in EUR. Nullable when feed reports `'NA'`. */\n gross_price_eur: number | null\n /** ISO 4217 currency code. Always `'EUR'` for v1 (D9); kept on the doc for forward-compat. */\n currency: string\n /** Manufacturer barcode, if present. */\n barcode: string | null\n /** Per-locale name fields. Stored; not indexed for full-text per D5. */\n name_de: string | null\n name_en: string | null\n name_es: string | null\n name_fr: string | null\n name_it: string | null\n name_nl: string | null\n name_pt: string | null\n /** Free-form description fields from the feed. Stored; not indexed for full-text. */\n description1: string | null\n description2: string | null\n /** Import-batch attribution — used for D7 source tagging and for stale-row cleanup. */\n import_batch_id: string\n /** When this batch landed in ES. */\n imported_at: string\n}\n\n/**\n * Index settings + mappings for the parts index.\n *\n * Settings tuned for ingestion throughput (refresh deferred during bulk\n * imports — caller calls `refresh: 'wait_for'` once at the end of a batch\n * if it needs the writes immediately visible).\n */\nexport const partsIndexConfig = {\n settings: {\n number_of_shards: 3,\n number_of_replicas: 1,\n /** Default 10000; bumped because admin pagination is bounded by mechanics, not by max_result_window. */\n max_result_window: 100000,\n refresh_interval: '30s',\n },\n mappings: {\n properties: {\n doc_id: { type: 'keyword' },\n code: { type: 'keyword' },\n code_normalized: { type: 'keyword' },\n brand_id: { type: 'keyword' },\n brand_slug: { type: 'keyword' },\n supplier_id: { type: 'keyword' },\n supplier_display_name: { type: 'keyword' },\n net_price_eur: { type: 'float' },\n gross_price_eur: { type: 'float' },\n currency: { type: 'keyword' },\n barcode: { type: 'keyword' },\n // Per-locale display names. `text` so they're searchable if a future\n // editorial-overlay use case wires full-text on them; the parts\n // ElasticsearchProvider does NOT search across these per D5.\n name_de: { type: 'text', index: false },\n name_en: { type: 'text', index: false },\n name_es: { type: 'text', index: false },\n name_fr: { type: 'text', index: false },\n name_it: { type: 'text', index: false },\n name_nl: { type: 'text', index: false },\n name_pt: { type: 'text', index: false },\n description1: { type: 'text', index: false },\n description2: { type: 'text', index: false },\n import_batch_id: { type: 'keyword' },\n imported_at: { type: 'date' },\n },\n },\n} as const\n","/**\n * Alias-versioned index management (D6).\n *\n * Every consumer queries through a stable alias; physical indices are\n * versioned. Mapping changes ship via:\n * 1. `createIndex(client, { name: <alias>_<version>, settings, mappings })`\n * 2. (consumer back-fills the new index with current data — typically\n * via the importer's bulkUpsert)\n * 3. `swapAlias(client, alias, fromIndex, toIndex)` — atomic\n * 4. `dropIndex(client, fromIndex)` after a confidence window\n *\n * `ensureAliasedIndex` covers the first-time-deploy case — when the alias\n * doesn't exist, create the first physical index and point the alias at\n * it. Idempotent: re-running on an already-created alias is a no-op.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport type { AliasAction, EsClientLike } from './client.js'\n\nexport interface IndexConfig {\n settings?: estypes.IndicesIndexSettings\n mappings?: estypes.MappingTypeMapping\n}\n\n/**\n * Create a physical index. Throws if the index already exists; callers that\n * want idempotent creation should use {@link ensureAliasedIndex} or check\n * `client.indices.exists` first.\n */\nexport async function createIndex(\n client: EsClientLike,\n args: { name: string; config: IndexConfig },\n): Promise<void> {\n const { name, config } = args\n await client.indices.create({\n index: name,\n ...(config.settings !== undefined && { settings: config.settings }),\n ...(config.mappings !== undefined && { mappings: config.mappings }),\n })\n}\n\n/**\n * Atomically point an alias at a new physical index.\n *\n * When `fromIndex` is provided, removes the alias from it in the same\n * `updateAliases` call as the add — ES guarantees atomicity per docs.\n * Readers see either the old index or the new index, never neither.\n */\nexport async function swapAlias(\n client: EsClientLike,\n args: { alias: string; toIndex: string; fromIndex?: string },\n): Promise<void> {\n const { alias, toIndex, fromIndex } = args\n const actions: AliasAction[] = []\n if (fromIndex !== undefined && fromIndex !== toIndex) {\n actions.push({ remove: { index: fromIndex, alias } })\n }\n actions.push({ add: { index: toIndex, alias } })\n await client.indices.updateAliases({ actions })\n}\n\n/** Delete a physical index. */\nexport async function dropIndex(client: EsClientLike, name: string): Promise<void> {\n await client.indices.delete({ index: name })\n}\n\n/**\n * Ensure an alias exists pointing at a physical index. Used at boot /\n * first deploy. Idempotent; safe to re-run.\n *\n * Behavior:\n * - If the alias already exists → no-op, returns the current physical index name.\n * - If a physical index named `<alias>_v1` exists but the alias does NOT\n * point at it → swap the alias onto it (without dropping anything).\n * - Otherwise → create `<alias>_v1` with the supplied config and point\n * the alias at it.\n *\n * Returns the physical index name the alias is pointing at on completion.\n */\nexport async function ensureAliasedIndex(\n client: EsClientLike,\n args: { alias: string; config: IndexConfig },\n): Promise<string> {\n const { alias, config } = args\n\n // Try to read the existing alias mapping; ES throws when nothing matches,\n // so swallow that single case (and only that one) and treat as \"no alias yet\".\n const aliased = await readAliasIndex(client, alias)\n if (aliased !== null) return aliased\n\n const initialName = `${alias}_v1`\n const initialExists = await client.indices.exists({ index: initialName })\n if (!initialExists) {\n await createIndex(client, { name: initialName, config })\n }\n await swapAlias(client, { alias, toIndex: initialName })\n return initialName\n}\n\n/**\n * Resolve the physical index an alias currently points at, or null when\n * the alias doesn't exist. Used by callers that want to know the current\n * write target before issuing a reindex.\n *\n * **Multi-index aliases:** during a rolling reindex an alias can point at\n * more than one physical index. This function returns the\n * **lexicographically-LAST** name. With versioned naming\n * (`<alias>_v1`, `<alias>_v2`, ...) that's the newest target — which\n * matches what callers walking the alias-swap flow generally want\n * (write to the new index, retire the old). Callers that need the\n * complete set should use `client.indices.getAlias` directly.\n */\nexport async function readAliasIndex(\n client: EsClientLike,\n alias: string,\n): Promise<string | null> {\n try {\n const response = await client.indices.getAlias({ name: alias })\n const indices = Object.keys(response)\n if (indices.length === 0) return null\n // Lexicographic-last: with `<alias>_v<N>` names, this picks the highest N.\n return [...indices].sort()[indices.length - 1] ?? null\n } catch (error: unknown) {\n if (isNotFoundError(error)) return null\n throw error\n }\n}\n\n/**\n * Recognises the structured 404 the ES client throws when an alias/index\n * doesn't exist. We REQUIRE BOTH `meta.statusCode === 404` AND a\n * recognized ES `body.error.type` — a bare HTTP 404 from a misconfigured\n * proxy / load balancer (Cloudflare, ALB) would otherwise be silently\n * misread as \"alias missing\", and `ensureAliasedIndex` would then go on\n * to create a duplicate index. Bare 404s without ES context bubble up as\n * real errors so the operator sees the proxy issue rather than corrupted\n * index state.\n */\nfunction isNotFoundError(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) return false\n const e = error as {\n meta?: { statusCode?: number }\n body?: { error?: { type?: string } }\n }\n if (e.meta?.statusCode !== 404) return false\n const errorType = e.body?.error?.type\n return errorType === 'index_not_found_exception' || errorType === 'alias_not_found_exception'\n}\n","/**\n * Bulk upsert helper used by the imports package (PR 7) to write feed rows\n * into ES. Each doc keys on its `_id` so re-running the same import batch\n * is idempotent (the second run overwrites instead of duplicating).\n *\n * Per D21 in PLAN-ECOMMERCE.md: feed-driven writes bypass entity hooks /\n * AdminClient — that is the sanctioned escape, not a backdoor. This helper\n * is the bulk-write surface the importer is allowed to use; nothing else\n * should call it directly. Per-batch audit logging happens in the importer.\n */\n\nimport type { BulkOperation, BulkResponse, EsClientLike } from './client.js'\n\nexport interface BulkIndexInput<TDoc> {\n /** ES alias or physical index name to write to. */\n index: string\n /** Documents to upsert. Each MUST carry the `_id` ES will use as primary key. */\n docs: ReadonlyArray<{ id: string; doc: TDoc }>\n /**\n * Refresh policy. Default `false` (best for throughput; readers see writes\n * after the next refresh interval). Tests and PoC search-after-import flows\n * may set `'wait_for'` to read-your-writes.\n */\n refresh?: boolean | 'wait_for'\n /**\n * Optional abort signal. Threaded into the underlying `client.bulk` call\n * via `RequestOptions` so a cancelled queue job actually stops the\n * in-flight request rather than the worker waiting for a multi-second\n * batch to complete before noticing the cancellation.\n */\n signal?: AbortSignal\n}\n\nexport interface BulkIndexResult {\n took: number\n /** Total documents the caller submitted. */\n submitted: number\n /** Documents the cluster acknowledged successfully. */\n succeeded: number\n /**\n * Per-doc failures. The bulk operation is partial-success by design — one\n * bad row doesn't reject the rest of the batch. Caller handles these\n * (importer aggregates into `ErrorTracker` patterns).\n */\n failures: ReadonlyArray<BulkIndexFailure>\n}\n\nexport interface BulkIndexFailure {\n id: string\n /** ES error type, e.g. `mapper_parsing_exception`. */\n type: string\n /** Human-readable reason from ES. */\n reason: string\n /** HTTP-style status: 4xx for client mistakes, 5xx for cluster issues. */\n status: number\n}\n\n/**\n * Bulk-index a batch of documents. Returns per-doc success / failure detail\n * — does NOT throw on partial failure. The importer is responsible for\n * deciding when accumulated failures cross a threshold that should fail\n * the import_run.\n */\nexport async function bulkUpsert<TDoc>(\n client: EsClientLike,\n args: BulkIndexInput<TDoc>,\n): Promise<BulkIndexResult> {\n const { index, docs, refresh, signal } = args\n if (docs.length === 0) {\n return { took: 0, submitted: 0, succeeded: 0, failures: [] }\n }\n\n const operations: BulkOperation[] = []\n for (const { id, doc } of docs) {\n operations.push({ index: { _index: index, _id: id } })\n operations.push(doc as unknown as Record<string, unknown>)\n }\n\n const response: BulkResponse = await client.bulk(\n {\n operations,\n ...(refresh !== undefined && { refresh }),\n },\n // signal lives in the second options arg per the real ES v8 client\n // contract (see RequestOptions JSDoc on client.ts). Conditionally\n // pass the options object so we don't introduce undefined into the\n // signature when caller didn't supply one.\n ...(signal !== undefined ? [{ signal }] : []),\n )\n\n if (!response.errors) {\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length,\n failures: [],\n }\n }\n\n const failures: BulkIndexFailure[] = []\n for (const item of response.items) {\n // We only ever emit `index` actions, so the response item key is\n // stable. Read it directly — the previous Object.values fallback\n // promised tolerance the code couldn't actually deliver (ES legitimately\n // echoing two keys would still break). If a future call site needs\n // create/update/delete, extend the action match here.\n const result = item.index\n if (!result || result.error === undefined) continue\n failures.push({\n // Real ES `BulkResponseItem._id: string | null | undefined`. Cover\n // both null and undefined with the same fallback.\n id: result._id ?? '<unknown>',\n type: result.error.type,\n // Real ES `ErrorCauseKeys.reason?: string | null` — both null and\n // missing are legitimate; fall back to a placeholder rather than\n // letting `null` typed as `string` smuggle through.\n reason: result.error.reason ?? '<no reason>',\n status: result.status,\n })\n }\n\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length - failures.length,\n failures,\n }\n}\n","/**\n * Reindex worker — STUB.\n *\n * Per the §6 PoC track scope in PLAN-ECOMMERCE.md, the reindex worker is\n * deferred. The PoC import path writes through `bulkUpsert` directly into\n * the live alias's underlying index, which is enough for the\n * single-supplier validation and won't survive a mapping change. Once the\n * PoC validates, the full reindex worker (queue-driven, resumable,\n * progress-reporting via PR 0) lands here.\n *\n * The stub function exists so consumers can wire up the call shape now\n * and get a loud error instead of a silent no-op when they invoke it\n * pre-completion.\n */\n\nimport type { EsClientLike } from './client.js'\n\nexport interface ReindexJobInput {\n client: EsClientLike\n alias: string\n /** Source physical index. */\n fromIndex: string\n /** Destination physical index (must already exist with the new mapping). */\n toIndex: string\n /** Optional progress reporter (provided by `@murumets-ee/queue` job context). */\n reportProgress?: (progress: { processed: number; total: number }) => Promise<void>\n}\n\n/**\n * **STUB — throws unconditionally.** PoC scope per PLAN-ECOMMERCE.md §6.0.\n * A future implementation will drive a resumable reindex from `fromIndex`\n * to `toIndex` and atomically swap the alias on completion.\n *\n * When implemented, the canonical flow is:\n * 1. Read in batches via the ES scroll / search_after API (resumable —\n * the cursor lives in the queue job's `progress` payload).\n * 2. For each batch, write via `bulkUpsert` to `toIndex`.\n * 3. On completion, `swapAlias(client, alias, fromIndex, toIndex)`.\n * 4. Caller decides when to `dropIndex(fromIndex)` after a confidence window.\n */\nexport async function reindex(_input: ReindexJobInput): Promise<never> {\n throw new Error(\n 'reindex() is not implemented — deferred per PLAN-ECOMMERCE.md §6 PoC track. ' +\n 'The PoC import flow writes through bulkUpsert into the live aliased index. ' +\n 'When mapping-changing reindex is needed, finish this implementation per the ' +\n 'JSDoc on this function.',\n )\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["typedBuckets"],"sources":["../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"sourcesContent":["/**\n * `ElasticsearchProvider` — implements the generic SearchProvider contract\n * from `@murumets-ee/search`, backed by an ES client (or anything matching\n * the `EsClientLike` structural interface).\n *\n * Per D5 in PLAN-ECOMMERCE.md the parts use case is term + prefix only —\n * but this class is generic over any ES use case, so it implements all\n * three modes (`term`, `prefix`, `phrase`). Consumers gate UI behavior via\n * the configurable `capabilities` flag.\n *\n * Per D14 the consumer supplies the `transform` projection from raw doc\n * → SearchResultRow; per D15 facets are subject to the same WHERE filter as\n * the underlying query (we just compute aggs in the same search call, so\n * the row-level scoping applies automatically).\n *\n * Per the binding constraint on `FacetFilters` (PR 1): field names are\n * arbitrary user strings — the `filterableFields` whitelist is the only\n * line of defense against ES query injection. The constructor throws when\n * the whitelist is empty AND filters were configured to be acceptable.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport type {\n FacetAggregation,\n FacetBucket,\n FacetFilters,\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport type { EsClientLike, SearchRequest, SearchResponse } from './client.js'\n\nexport interface ElasticsearchProviderConfig<TDoc> {\n /** Logical resource name (registry key, route segment, audit tag). */\n resource: string\n /**\n * Permission resource for the framework `view` check. Defaults to\n * `resource`. Override when the search resource and the entity-level\n * permission diverge (e.g. resource `parts` gated on `commerce.parts:view`).\n */\n permissionResource?: string\n /** ES client (or structural equivalent for tests). */\n client: EsClientLike\n /** Alias to query — the alias-versioned indirection from D6. */\n indexAlias: string\n /**\n * Whitelist of fields used for `term` / `prefix` matching. Each entry\n * MUST be a `keyword`-typed field on the index mapping; ES `term` and\n * `prefix` queries on `text` fields silently miss what they look like\n * they should match.\n *\n * The first field is the primary; for `term` mode, all listed fields\n * are queried with OR semantics.\n */\n searchableFields: readonly string[]\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`\n * (`?f.<field>=<value>`). Defaults to `searchableFields`. Anything\n * outside this list is dropped — the single line of defense against the\n * arbitrary-field-name injection vector documented on `FacetFilters`.\n */\n filterableFields?: readonly string[]\n /**\n * Whitelist of fields to compute facet buckets for. Subset of\n * `filterableFields`. ES applies the same WHERE filter as the search\n * query, so facet counts respect row-level scoping (D15).\n */\n facetFields?: readonly string[]\n /** Per-facet bucket cap. Default 50 — matches typical UI panel size. */\n facetSize?: number\n /**\n * Override capabilities. Defaults: `{fullText:true, ranking:true,\n * facets:true, fuzzy:false, prefix:true}`. Per D5 the parts consumer\n * declares `{fullText:false, ranking:false, fuzzy:false}` so the search\n * box doesn't render full-text affordances.\n */\n capabilities?: Partial<SearchCapabilities>\n /** Project an ES hit into a SearchResultRow (D14 — required, no default). */\n transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n}\n\nconst DEFAULT_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: true,\n fuzzy: false,\n prefix: true,\n}\n\nconst DEFAULT_FACET_SIZE = 50\n\nexport class ElasticsearchProvider<TDoc = Record<string, unknown>>\n implements SearchProvider\n{\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities: SearchCapabilities\n\n private readonly client: EsClientLike\n private readonly indexAlias: string\n private readonly searchableFields: readonly string[]\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly facetFields: readonly string[]\n private readonly facetSize: number\n private readonly transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n\n constructor(config: ElasticsearchProviderConfig<TDoc>) {\n if (config.searchableFields.length === 0) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' requires at least one searchable field`,\n )\n }\n const filterable = config.filterableFields ?? config.searchableFields\n const facets = config.facetFields ?? []\n for (const facet of facets) {\n if (!filterable.includes(facet)) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' facet field '${facet}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`,\n )\n }\n }\n\n this.resource = config.resource\n if (config.permissionResource !== undefined) {\n this.permissionResource = config.permissionResource\n }\n this.capabilities = { ...DEFAULT_CAPABILITIES, ...(config.capabilities ?? {}) }\n this.client = config.client\n this.indexAlias = config.indexAlias\n this.searchableFields = config.searchableFields\n this.filterableFieldSet = new Set(filterable)\n this.facetFields = facets\n this.facetSize = config.facetSize ?? DEFAULT_FACET_SIZE\n this.transform = config.transform\n }\n\n async search(input: SearchInput, signal: AbortSignal): Promise<SearchResult> {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n\n const request: SearchRequest = {\n index: this.indexAlias,\n query: this.buildQuery(input),\n size: input.limit,\n from: input.offset,\n // ES 8+ caps total at 10000 by default — opt into exact counts so\n // the displayed total isn't a ceiling masquerading as truth.\n track_total_hits: true,\n }\n if (this.facetFields.length > 0) {\n request.aggs = this.buildAggs()\n }\n\n const start = Date.now()\n let response: SearchResponse<TDoc>\n try {\n // signal goes into the SECOND options arg — the real ES v8 client\n // ignores `signal` placed on the request params (see RequestOptions\n // JSDoc on client.ts).\n response = await this.client.search<TDoc>(request, { signal })\n } catch (error: unknown) {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout, { cause: error })\n }\n throw error\n }\n const durationMs = Date.now() - start\n\n const total = readTotal(response.hits.total)\n\n // Real ES `SearchHit` has `_id?: Id`, `_source?: TDocument`, `_score?:\n // number | null` — all optional. Skip hits missing the fields we need\n // rather than crashing the whole search; the missing-field shapes are\n // rare (require explicit `_source: false` or specific PIT/scroll\n // responses) but the type system surfaces them honestly now.\n const rows: SearchResultRow[] = []\n for (const hit of response.hits.hits) {\n if (hit._id === undefined || hit._source === undefined) continue\n rows.push(this.transform(hit._source, hit._score ?? null, hit._id))\n }\n\n return {\n rows,\n total,\n facets: this.collectFacets(response.aggregations ?? {}),\n durationMs,\n }\n }\n\n /**\n * Compose the ES query body. Combines the user query (mode-dependent)\n * with the filter WHERE (whitelist-gated) under a single `bool.filter`\n * — `filter` instead of `must` so docs aren't scored by the filters\n * (faster, cacheable). The text query goes in `must` so scoring still\n * applies to phrase mode.\n *\n * `searchableFields` is non-empty by construction (constructor throws\n * on empty input), so `buildQueryClause` always returns a clause —\n * `must.push` runs unconditionally.\n */\n private buildQuery(input: SearchInput): estypes.QueryDslQueryContainer {\n const must: estypes.QueryDslQueryContainer[] = [this.buildQueryClause(input)]\n const filter = this.buildFilterClauses(input.filters)\n return {\n bool: {\n must,\n ...(filter.length > 0 && { filter }),\n },\n }\n }\n\n private buildQueryClause(input: SearchInput): estypes.QueryDslQueryContainer {\n const verb = QUERY_VERB_BY_MODE[input.mode]\n return wrapShouldOr(this.searchableFields, (field) => ({\n [verb]: { [field]: input.query },\n }))\n }\n\n /**\n * Build `terms`-clause filters from caller-supplied `filters`. Field\n * names not in the whitelist are silently dropped; multi-value entries\n * use ES `terms` (OR within field), single values use `term`. The\n * caller-facing semantic (\"this field=these values\") matches IlikeProvider.\n */\n private buildFilterClauses(filters: FacetFilters): estypes.QueryDslQueryContainer[] {\n const out: estypes.QueryDslQueryContainer[] = []\n for (const [field, values] of Object.entries(filters)) {\n if (!this.filterableFieldSet.has(field)) continue\n if (values.length === 0) continue\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) continue\n out.push({ term: { [field]: single } })\n } else {\n out.push({ terms: { [field]: [...values] } })\n }\n }\n return out\n }\n\n private buildAggs(): Record<string, estypes.AggregationsAggregationContainer> {\n const aggs: Record<string, estypes.AggregationsAggregationContainer> = {}\n for (const field of this.facetFields) {\n aggs[field] = { terms: { field, size: this.facetSize } }\n }\n return aggs\n }\n\n private collectFacets(\n aggregations: Record<string, estypes.AggregationsAggregate>,\n ): readonly FacetAggregation[] {\n const out: FacetAggregation[] = []\n for (const field of this.facetFields) {\n const agg = aggregations[field]\n if (!agg) continue\n // We only emit terms aggregations (see buildAggs), which always\n // return array-form buckets. `AggregationsAggregate` is a union of\n // ~70 aggregate shapes; structurally probe for the bucket array\n // rather than narrowing the entire union. ES's `AggregationsBuckets`\n // can also be `Record<string, T>` (filter-style aggs) — those are\n // config drift here, ignore rather than misrepresent.\n const rawBuckets = (agg as { buckets?: unknown }).buckets\n if (!Array.isArray(rawBuckets)) continue\n const typedBuckets = rawBuckets as ReadonlyArray<unknown>\n const buckets: FacetBucket[] = typedBuckets.map((b) => {\n // ES `FieldValue` officially permits `any`; in practice a terms\n // aggregation only emits `string | number | boolean`. Narrow to\n // that here so the cast doesn't smuggle `any` through (and the\n // package's 100% type-coverage floor stays a real signal).\n const bucket = b as { key: string | number | boolean; doc_count: number }\n return {\n // Coerce so the FacetBucket.value: string contract holds.\n value: String(bucket.key),\n count: bucket.doc_count,\n }\n })\n out.push({ field, buckets })\n }\n return out\n }\n}\n\nconst QUERY_VERB_BY_MODE: Record<SearchInput['mode'], 'term' | 'prefix' | 'match_phrase'> = {\n term: 'term',\n prefix: 'prefix',\n phrase: 'match_phrase',\n}\n\n/**\n * Build a single-clause query (when `fields` has one entry) or a\n * `bool.should` OR over N field-clauses with `minimum_should_match: 1`.\n * Caller passes a per-field clause builder; this collapses the\n * single-vs-multi shape repetition that used to live in three branches.\n */\nfunction wrapShouldOr(\n fields: readonly string[],\n perField: (field: string) => estypes.QueryDslQueryContainer,\n): estypes.QueryDslQueryContainer {\n // Constructor enforces `fields.length >= 1`, so `fields[0]` is always\n // defined. Throw on the impossible-state path so a future regression\n // lands loudly rather than producing a silent `match_all`.\n const onlyField = fields[0]\n if (onlyField === undefined) {\n throw new Error('unreachable: searchableFields cannot be empty (constructor enforces)')\n }\n if (fields.length === 1) return perField(onlyField)\n return {\n bool: {\n should: fields.map(perField),\n minimum_should_match: 1,\n },\n }\n}\n\n/**\n * ES total can be `{ value, relation } | number | undefined`. The\n * `undefined` shape is rare (some scroll/PIT responses) but real — so\n * treat it as `0` rather than reading `.value` on undefined and\n * crashing.\n */\nfunction readTotal(total: SearchResponse<unknown>['hits']['total']): number {\n if (total === undefined) return 0\n if (typeof total === 'number') return total\n return total.value\n}\n","/**\n * Parts catalog mapping.\n *\n * Per D4 in PLAN-ECOMMERCE.md: one ES doc per `(code_normalized, supplier_id)`,\n * `_id = \"<code>__<supplier_id>\"`. Consumers read this via the alias\n * (`PARTS_INDEX_ALIAS`) and never address a physical index name directly so\n * a mapping change can be deployed via D6 alias-versioned reindex.\n *\n * Per D5: search modes are `term` (exact-equality on `code_normalized`) and\n * `prefix` (starts-with on `code_normalized`). No fuzzy / wildcard / multi-\n * field boost. Description fields are stored, not indexed for full-text.\n *\n * The mapping is hardcoded for the PoC (per the §6 PoC track scope) — when\n * a non-parts ES use case arrives, factor out a shared helper. Until then,\n * one mapping shape avoids the abstraction cost of a generic mapping\n * registry that has zero second consumer to validate it against.\n */\n\n/** Stable alias every reader queries through. Physical indices are versioned. */\nexport const PARTS_INDEX_ALIAS = 'parts'\n\n/** Document shape written by the importer; read by ElasticsearchProvider. */\nexport interface PartsDocument {\n /** `<code_normalized>__<supplier_id>` — also the ES `_id`. */\n doc_id: string\n /** Code as written by the supplier (`ME-A0000000000`). Display-only. */\n code: string\n /** Bare manufacturer code, uppercased + dash/space-stripped. Used for term + prefix. */\n code_normalized: string\n /** Brand UUID — keyed off `brand.id` per D24 (typed FK, not a string). */\n brand_id: string\n /** Brand slug — used as the facet bucket label and for human-readable filters. */\n brand_slug: string\n /** Supplier UUID. */\n supplier_id: string\n /** Supplier customer-facing alias (D24 anti-disintermediation). NEVER `supplier.name`. */\n supplier_display_name: string\n /** Net price in EUR (D9 single-currency). */\n net_price_eur: number\n /** Gross price in EUR. Nullable when feed reports `'NA'`. */\n gross_price_eur: number | null\n /** ISO 4217 currency code. Always `'EUR'` for v1 (D9); kept on the doc for forward-compat. */\n currency: string\n /** Manufacturer barcode, if present. */\n barcode: string | null\n /** Per-locale name fields. Stored; not indexed for full-text per D5. */\n name_de: string | null\n name_en: string | null\n name_es: string | null\n name_fr: string | null\n name_it: string | null\n name_nl: string | null\n name_pt: string | null\n /** Free-form description fields from the feed. Stored; not indexed for full-text. */\n description1: string | null\n description2: string | null\n /** Import-batch attribution — used for D7 source tagging and for stale-row cleanup. */\n import_batch_id: string\n /** When this batch landed in ES. */\n imported_at: string\n}\n\n/**\n * Index settings + mappings for the parts index.\n *\n * Settings tuned for ingestion throughput (refresh deferred during bulk\n * imports — caller calls `refresh: 'wait_for'` once at the end of a batch\n * if it needs the writes immediately visible).\n */\nexport const partsIndexConfig = {\n settings: {\n number_of_shards: 3,\n number_of_replicas: 1,\n /** Default 10000; bumped because admin pagination is bounded by mechanics, not by max_result_window. */\n max_result_window: 100000,\n refresh_interval: '30s',\n },\n mappings: {\n properties: {\n doc_id: { type: 'keyword' },\n code: { type: 'keyword' },\n code_normalized: { type: 'keyword' },\n brand_id: { type: 'keyword' },\n brand_slug: { type: 'keyword' },\n supplier_id: { type: 'keyword' },\n supplier_display_name: { type: 'keyword' },\n net_price_eur: { type: 'float' },\n gross_price_eur: { type: 'float' },\n currency: { type: 'keyword' },\n barcode: { type: 'keyword' },\n // Per-locale display names. `text` so they're searchable if a future\n // editorial-overlay use case wires full-text on them; the parts\n // ElasticsearchProvider does NOT search across these per D5.\n name_de: { type: 'text', index: false },\n name_en: { type: 'text', index: false },\n name_es: { type: 'text', index: false },\n name_fr: { type: 'text', index: false },\n name_it: { type: 'text', index: false },\n name_nl: { type: 'text', index: false },\n name_pt: { type: 'text', index: false },\n description1: { type: 'text', index: false },\n description2: { type: 'text', index: false },\n import_batch_id: { type: 'keyword' },\n imported_at: { type: 'date' },\n },\n },\n} as const\n","/**\n * Alias-versioned index management (D6).\n *\n * Every consumer queries through a stable alias; physical indices are\n * versioned. Mapping changes ship via:\n * 1. `createIndex(client, { name: <alias>_<version>, settings, mappings })`\n * 2. (consumer back-fills the new index with current data — typically\n * via the importer's bulkUpsert)\n * 3. `swapAlias(client, alias, fromIndex, toIndex)` — atomic\n * 4. `dropIndex(client, fromIndex)` after a confidence window\n *\n * `ensureAliasedIndex` covers the first-time-deploy case — when the alias\n * doesn't exist, create the first physical index and point the alias at\n * it. Idempotent: re-running on an already-created alias is a no-op.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport type { AliasAction, EsClientLike } from './client.js'\n\nexport interface IndexConfig {\n settings?: estypes.IndicesIndexSettings\n mappings?: estypes.MappingTypeMapping\n}\n\n/**\n * Create a physical index. Throws if the index already exists; callers that\n * want idempotent creation should use {@link ensureAliasedIndex} or check\n * `client.indices.exists` first.\n */\nexport async function createIndex(\n client: EsClientLike,\n args: { name: string; config: IndexConfig },\n): Promise<void> {\n const { name, config } = args\n await client.indices.create({\n index: name,\n ...(config.settings !== undefined && { settings: config.settings }),\n ...(config.mappings !== undefined && { mappings: config.mappings }),\n })\n}\n\n/**\n * Atomically point an alias at a new physical index.\n *\n * When `fromIndex` is provided, removes the alias from it in the same\n * `updateAliases` call as the add — ES guarantees atomicity per docs.\n * Readers see either the old index or the new index, never neither.\n */\nexport async function swapAlias(\n client: EsClientLike,\n args: { alias: string; toIndex: string; fromIndex?: string },\n): Promise<void> {\n const { alias, toIndex, fromIndex } = args\n const actions: AliasAction[] = []\n if (fromIndex !== undefined && fromIndex !== toIndex) {\n actions.push({ remove: { index: fromIndex, alias } })\n }\n actions.push({ add: { index: toIndex, alias } })\n await client.indices.updateAliases({ actions })\n}\n\n/** Delete a physical index. */\nexport async function dropIndex(client: EsClientLike, name: string): Promise<void> {\n await client.indices.delete({ index: name })\n}\n\n/**\n * Ensure an alias exists pointing at a physical index. Used at boot /\n * first deploy. Idempotent; safe to re-run.\n *\n * Behavior:\n * - If the alias already exists → no-op, returns the current physical index name.\n * - If a physical index named `<alias>_v1` exists but the alias does NOT\n * point at it → swap the alias onto it (without dropping anything).\n * - Otherwise → create `<alias>_v1` with the supplied config and point\n * the alias at it.\n *\n * Returns the physical index name the alias is pointing at on completion.\n */\nexport async function ensureAliasedIndex(\n client: EsClientLike,\n args: { alias: string; config: IndexConfig },\n): Promise<string> {\n const { alias, config } = args\n\n // Try to read the existing alias mapping; ES throws when nothing matches,\n // so swallow that single case (and only that one) and treat as \"no alias yet\".\n const aliased = await readAliasIndex(client, alias)\n if (aliased !== null) return aliased\n\n // Defensive check: a PHYSICAL index with the alias's name (no alias\n // pointing at it) is a known foot-gun. ES auto-creates indices on\n // first write, so any worker that bulk-indexes against `alias` BEFORE\n // bootstrap runs ends up with a same-named physical index that uses\n // ES's auto-inferred mappings (every string field becomes `text`,\n // breaking aggregations / sorting / facets that need keyword). The\n // bootstrap that runs later then can't add the alias because the name\n // collides with the physical index, and the deployment is silently\n // stuck on the bad mapping.\n //\n // Detect explicitly and refuse to silently mis-bootstrap. Operator\n // remediation: drop the physical index (`DELETE /<alias>`) and\n // re-run; subsequent writes will land in `<alias>_v1` with the\n // intended mapping. Idempotent reset scripts may want to call\n // `dropIndex(client, alias)` themselves before invoking this.\n const physicalCollision = await client.indices.exists({ index: alias })\n if (physicalCollision) {\n throw new Error(\n `ensureAliasedIndex: a physical index named \"${alias}\" already exists with no alias attached. ` +\n `This usually means a worker bulk-indexed before the alias bootstrap ran, so ES auto-created ` +\n `the index with text-typed string fields (breaking facets / aggregations). Drop the index ` +\n `(\\`curl -XDELETE http://<es>/${alias}\\`) and re-run; subsequent imports will land in ` +\n `\"${alias}_v1\" with the intended mapping.`,\n )\n }\n\n const initialName = `${alias}_v1`\n const initialExists = await client.indices.exists({ index: initialName })\n if (!initialExists) {\n await createIndex(client, { name: initialName, config })\n }\n await swapAlias(client, { alias, toIndex: initialName })\n return initialName\n}\n\n/**\n * Resolve the physical index an alias currently points at, or null when\n * the alias doesn't exist. Used by callers that want to know the current\n * write target before issuing a reindex.\n *\n * **Multi-index aliases:** during a rolling reindex an alias can point at\n * more than one physical index. This function returns the\n * **lexicographically-LAST** name. With versioned naming\n * (`<alias>_v1`, `<alias>_v2`, ...) that's the newest target — which\n * matches what callers walking the alias-swap flow generally want\n * (write to the new index, retire the old). Callers that need the\n * complete set should use `client.indices.getAlias` directly.\n */\nexport async function readAliasIndex(\n client: EsClientLike,\n alias: string,\n): Promise<string | null> {\n try {\n const response = await client.indices.getAlias({ name: alias })\n const indices = Object.keys(response)\n if (indices.length === 0) return null\n // Lexicographic-last: with `<alias>_v<N>` names, this picks the highest N.\n return [...indices].sort()[indices.length - 1] ?? null\n } catch (error: unknown) {\n if (isNotFoundError(error)) return null\n throw error\n }\n}\n\n/**\n * Recognises the structured 404 the ES client throws when an alias/index\n * doesn't exist. We REQUIRE BOTH `meta.statusCode === 404` AND ES-shaped\n * error payload — a bare HTTP 404 from a misconfigured proxy / load\n * balancer (Cloudflare, ALB) would otherwise be silently misread as\n * \"alias missing\", and `ensureAliasedIndex` would then go on to create\n * a duplicate index. Bare 404s without ES context bubble up as real\n * errors so the operator sees the proxy issue rather than corrupted\n * index state.\n *\n * ES has two body shapes for the alias-missing case depending on path:\n * - Structured: `body.error.type === 'alias_not_found_exception'`\n * (the `_alias` API throws this for some calls).\n * - Flat-string: `body.error === 'alias [<name>] missing'`\n * (the `indices.getAlias` API in ES 8.x throws this — confirmed\n * against `8.15.0` in the playground docker-compose).\n *\n * Match either; both are unambiguously ES-shaped (the body structure\n * itself is the discriminator vs. a proxy 404, which has neither shape).\n */\nfunction isNotFoundError(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) return false\n const e = error as {\n meta?: { statusCode?: number }\n body?: { error?: string | { type?: string } }\n }\n if (e.meta?.statusCode !== 404) return false\n const body = e.body?.error\n if (typeof body === 'string') {\n // Flat-string variant — the `indices.getAlias` shape in ES 8.x.\n // \"alias [<name>] missing\" / \"alias not found\" / similar.\n return /alias.*missing|alias_not_found|index_not_found/i.test(body)\n }\n if (body !== undefined) {\n return body.type === 'index_not_found_exception' || body.type === 'alias_not_found_exception'\n }\n return false\n}\n","/**\n * Bulk upsert helper used by the imports package (PR 7) to write feed rows\n * into ES. Each doc keys on its `_id` so re-running the same import batch\n * is idempotent (the second run overwrites instead of duplicating).\n *\n * Per D21 in PLAN-ECOMMERCE.md: feed-driven writes bypass entity hooks /\n * AdminClient — that is the sanctioned escape, not a backdoor. This helper\n * is the bulk-write surface the importer is allowed to use; nothing else\n * should call it directly. Per-batch audit logging happens in the importer.\n */\n\nimport type { BulkOperation, BulkResponse, EsClientLike } from './client.js'\n\nexport interface BulkIndexInput<TDoc> {\n /** ES alias or physical index name to write to. */\n index: string\n /** Documents to upsert. Each MUST carry the `_id` ES will use as primary key. */\n docs: ReadonlyArray<{ id: string; doc: TDoc }>\n /**\n * Refresh policy. Default `false` (best for throughput; readers see writes\n * after the next refresh interval). Tests and PoC search-after-import flows\n * may set `'wait_for'` to read-your-writes.\n */\n refresh?: boolean | 'wait_for'\n /**\n * Optional abort signal. Threaded into the underlying `client.bulk` call\n * via `RequestOptions` so a cancelled queue job actually stops the\n * in-flight request rather than the worker waiting for a multi-second\n * batch to complete before noticing the cancellation.\n */\n signal?: AbortSignal\n}\n\nexport interface BulkIndexResult {\n took: number\n /** Total documents the caller submitted. */\n submitted: number\n /** Documents the cluster acknowledged successfully. */\n succeeded: number\n /**\n * Per-doc failures. The bulk operation is partial-success by design — one\n * bad row doesn't reject the rest of the batch. Caller handles these\n * (importer aggregates into `ErrorTracker` patterns).\n */\n failures: ReadonlyArray<BulkIndexFailure>\n}\n\nexport interface BulkIndexFailure {\n id: string\n /** ES error type, e.g. `mapper_parsing_exception`. */\n type: string\n /** Human-readable reason from ES. */\n reason: string\n /** HTTP-style status: 4xx for client mistakes, 5xx for cluster issues. */\n status: number\n}\n\n/**\n * Bulk-index a batch of documents. Returns per-doc success / failure detail\n * — does NOT throw on partial failure. The importer is responsible for\n * deciding when accumulated failures cross a threshold that should fail\n * the import_run.\n */\nexport async function bulkUpsert<TDoc>(\n client: EsClientLike,\n args: BulkIndexInput<TDoc>,\n): Promise<BulkIndexResult> {\n const { index, docs, refresh, signal } = args\n if (docs.length === 0) {\n return { took: 0, submitted: 0, succeeded: 0, failures: [] }\n }\n\n const operations: BulkOperation[] = []\n for (const { id, doc } of docs) {\n operations.push({ index: { _index: index, _id: id } })\n operations.push(doc as unknown as Record<string, unknown>)\n }\n\n const response: BulkResponse = await client.bulk(\n {\n operations,\n ...(refresh !== undefined && { refresh }),\n },\n // signal lives in the second options arg per the real ES v8 client\n // contract (see RequestOptions JSDoc on client.ts). Conditionally\n // pass the options object so we don't introduce undefined into the\n // signature when caller didn't supply one.\n ...(signal !== undefined ? [{ signal }] : []),\n )\n\n if (!response.errors) {\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length,\n failures: [],\n }\n }\n\n const failures: BulkIndexFailure[] = []\n for (const item of response.items) {\n // We only ever emit `index` actions, so the response item key is\n // stable. Read it directly — the previous Object.values fallback\n // promised tolerance the code couldn't actually deliver (ES legitimately\n // echoing two keys would still break). If a future call site needs\n // create/update/delete, extend the action match here.\n const result = item.index\n if (!result || result.error === undefined) continue\n failures.push({\n // Real ES `BulkResponseItem._id: string | null | undefined`. Cover\n // both null and undefined with the same fallback.\n id: result._id ?? '<unknown>',\n type: result.error.type,\n // Real ES `ErrorCauseKeys.reason?: string | null` — both null and\n // missing are legitimate; fall back to a placeholder rather than\n // letting `null` typed as `string` smuggle through.\n reason: result.error.reason ?? '<no reason>',\n status: result.status,\n })\n }\n\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length - failures.length,\n failures,\n }\n}\n","/**\n * Reindex worker — STUB.\n *\n * Per the §6 PoC track scope in PLAN-ECOMMERCE.md, the reindex worker is\n * deferred. The PoC import path writes through `bulkUpsert` directly into\n * the live alias's underlying index, which is enough for the\n * single-supplier validation and won't survive a mapping change. Once the\n * PoC validates, the full reindex worker (queue-driven, resumable,\n * progress-reporting via PR 0) lands here.\n *\n * The stub function exists so consumers can wire up the call shape now\n * and get a loud error instead of a silent no-op when they invoke it\n * pre-completion.\n */\n\nimport type { EsClientLike } from './client.js'\n\nexport interface ReindexJobInput {\n client: EsClientLike\n alias: string\n /** Source physical index. */\n fromIndex: string\n /** Destination physical index (must already exist with the new mapping). */\n toIndex: string\n /** Optional progress reporter (provided by `@murumets-ee/queue` job context). */\n reportProgress?: (progress: { processed: number; total: number }) => Promise<void>\n}\n\n/**\n * **STUB — throws unconditionally.** PoC scope per PLAN-ECOMMERCE.md §6.0.\n * A future implementation will drive a resumable reindex from `fromIndex`\n * to `toIndex` and atomically swap the alias on completion.\n *\n * When implemented, the canonical flow is:\n * 1. Read in batches via the ES scroll / search_after API (resumable —\n * the cursor lives in the queue job's `progress` payload).\n * 2. For each batch, write via `bulkUpsert` to `toIndex`.\n * 3. On completion, `swapAlias(client, alias, fromIndex, toIndex)`.\n * 4. Caller decides when to `dropIndex(fromIndex)` after a confidence window.\n */\nexport async function reindex(_input: ReindexJobInput): Promise<never> {\n throw new Error(\n 'reindex() is not implemented — deferred per PLAN-ECOMMERCE.md §6 PoC track. ' +\n 'The PoC import flow writes through bulkUpsert into the live aliased index. ' +\n 'When mapping-changing reindex is needed, finish this implementation per the ' +\n 'JSDoc on this function.',\n )\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/search-elasticsearch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@elastic/elasticsearch": "^8.15.0",
|
|
17
|
-
"@murumets-ee/search": "0.
|
|
17
|
+
"@murumets-ee/search": "0.13.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^20.19.39",
|