@murumets-ee/search-elasticsearch 0.27.0 → 0.29.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 +92 -11
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.d.mts
CHANGED
|
@@ -225,9 +225,33 @@ interface PartsDocument {
|
|
|
225
225
|
name_it: string | null;
|
|
226
226
|
name_nl: string | null;
|
|
227
227
|
name_pt: string | null;
|
|
228
|
+
/**
|
|
229
|
+
* Romanian display name. Added for the 15-column carmaker feed drift
|
|
230
|
+
* (`Description_RO`) — see commerce-demo phase 02. Stored; not indexed for
|
|
231
|
+
* full-text per D5.
|
|
232
|
+
*/
|
|
233
|
+
name_ro: string | null;
|
|
228
234
|
/** Free-form description fields from the feed. Stored; not indexed for full-text. */
|
|
229
235
|
description1: string | null;
|
|
230
236
|
description2: string | null;
|
|
237
|
+
/** Supplier list (catalogue) price, minor units — pre-discount. PR3/PR4. */
|
|
238
|
+
list_price_minor: number | null;
|
|
239
|
+
/** OUR net purchase price, minor units — direct net, or `list × (1 − group%)`. PR4. */
|
|
240
|
+
purchase_price_minor: number | null;
|
|
241
|
+
/** Supplier discount-group name; joined to `supplier_discount_group` for two-file derivation. PR4. */
|
|
242
|
+
discount_group: string | null;
|
|
243
|
+
/** Manufacturer superseding / replacement code, if the feed carries one. PR6. */
|
|
244
|
+
superseding_code: string | null;
|
|
245
|
+
/** Core charge (Korpusetasu) deposit amount, minor units. PR7. */
|
|
246
|
+
core_charge_minor: number | null;
|
|
247
|
+
/** Core-charge currency (always `'EUR'` for v1, D9). PR7. */
|
|
248
|
+
core_charge_currency: string | null;
|
|
249
|
+
/** Whether the core charge is a returnable deposit (default true). PR7. */
|
|
250
|
+
core_charge_refundable: boolean | null;
|
|
251
|
+
/** Tax-NEUTRAL class reference for the core charge (D007 — future EU/US tax engine maps it). PR7. */
|
|
252
|
+
core_charge_tax_class: string | null;
|
|
253
|
+
/** Whether the imported core-charge amount already includes tax (EU feeds = false). D007. PR7. */
|
|
254
|
+
core_charge_tax_inclusive: boolean | null;
|
|
231
255
|
/** Import-batch attribution — used for D7 source tagging and for stale-row cleanup. */
|
|
232
256
|
import_batch_id: string;
|
|
233
257
|
/** When this batch landed in ES. */
|
|
@@ -310,6 +334,10 @@ declare const partsIndexConfig: {
|
|
|
310
334
|
readonly type: "text";
|
|
311
335
|
readonly index: false;
|
|
312
336
|
};
|
|
337
|
+
readonly name_ro: {
|
|
338
|
+
readonly type: "text";
|
|
339
|
+
readonly index: false;
|
|
340
|
+
};
|
|
313
341
|
readonly description1: {
|
|
314
342
|
readonly type: "text";
|
|
315
343
|
readonly index: false;
|
|
@@ -318,6 +346,33 @@ declare const partsIndexConfig: {
|
|
|
318
346
|
readonly type: "text";
|
|
319
347
|
readonly index: false;
|
|
320
348
|
};
|
|
349
|
+
readonly list_price_minor: {
|
|
350
|
+
readonly type: "long";
|
|
351
|
+
};
|
|
352
|
+
readonly purchase_price_minor: {
|
|
353
|
+
readonly type: "long";
|
|
354
|
+
};
|
|
355
|
+
readonly discount_group: {
|
|
356
|
+
readonly type: "keyword";
|
|
357
|
+
};
|
|
358
|
+
readonly superseding_code: {
|
|
359
|
+
readonly type: "keyword";
|
|
360
|
+
};
|
|
361
|
+
readonly core_charge_minor: {
|
|
362
|
+
readonly type: "long";
|
|
363
|
+
};
|
|
364
|
+
readonly core_charge_currency: {
|
|
365
|
+
readonly type: "keyword";
|
|
366
|
+
};
|
|
367
|
+
readonly core_charge_refundable: {
|
|
368
|
+
readonly type: "boolean";
|
|
369
|
+
};
|
|
370
|
+
readonly core_charge_tax_class: {
|
|
371
|
+
readonly type: "keyword";
|
|
372
|
+
};
|
|
373
|
+
readonly core_charge_tax_inclusive: {
|
|
374
|
+
readonly type: "boolean";
|
|
375
|
+
};
|
|
321
376
|
readonly import_batch_id: {
|
|
322
377
|
readonly type: "keyword";
|
|
323
378
|
};
|
|
@@ -444,30 +499,56 @@ declare function bulkUpsert<TDoc>(client: EsClientLike, args: BulkIndexInput<TDo
|
|
|
444
499
|
//#region src/reindex-worker.d.ts
|
|
445
500
|
interface ReindexJobInput {
|
|
446
501
|
client: EsClientLike;
|
|
502
|
+
/** Stable alias to swap onto `toIndex` once the copy completes. */
|
|
447
503
|
alias: string;
|
|
448
504
|
/** Source physical index. */
|
|
449
505
|
fromIndex: string;
|
|
450
506
|
/** Destination physical index (must already exist with the new mapping). */
|
|
451
507
|
toIndex: string;
|
|
508
|
+
/**
|
|
509
|
+
* Unique keyword field to sort by for stable `search_after` paging. MUST be
|
|
510
|
+
* unique per document or pages will skip / duplicate rows. Defaults to the
|
|
511
|
+
* parts index primary key `doc_id`.
|
|
512
|
+
*/
|
|
513
|
+
sortField?: string;
|
|
514
|
+
/** Documents per page (default 1000). */
|
|
515
|
+
batchSize?: number;
|
|
516
|
+
/**
|
|
517
|
+
* Refresh policy forwarded to each `bulkUpsert`. Pass `'wait_for'` when the
|
|
518
|
+
* caller needs the destination queryable the instant the alias swaps — the
|
|
519
|
+
* default 30s `refresh_interval` would otherwise leave a brief window where
|
|
520
|
+
* the swapped-in index returns no hits.
|
|
521
|
+
*/
|
|
522
|
+
refresh?: boolean | 'wait_for';
|
|
523
|
+
/** Optional abort signal threaded into every search + bulk call. */
|
|
524
|
+
signal?: AbortSignal;
|
|
452
525
|
/** Optional progress reporter (provided by `@murumets-ee/queue` job context). */
|
|
453
526
|
reportProgress?: (progress: {
|
|
454
527
|
processed: number;
|
|
455
528
|
total: number;
|
|
456
529
|
}) => Promise<void>;
|
|
457
530
|
}
|
|
531
|
+
interface ReindexResult {
|
|
532
|
+
fromIndex: string;
|
|
533
|
+
toIndex: string;
|
|
534
|
+
/** Documents successfully copied to the destination. */
|
|
535
|
+
processed: number;
|
|
536
|
+
/** Source hits skipped because they lacked an `_id` or `_source`. */
|
|
537
|
+
skipped: number;
|
|
538
|
+
}
|
|
458
539
|
/**
|
|
459
|
-
*
|
|
460
|
-
* A future implementation will drive a resumable reindex from `fromIndex`
|
|
461
|
-
* to `toIndex` and atomically swap the alias on completion.
|
|
540
|
+
* Reindex `fromIndex` → `toIndex` and swap `alias` onto the destination.
|
|
462
541
|
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
542
|
+
* Returns the number of documents copied + skipped. Throws when:
|
|
543
|
+
* - `toIndex` does not exist (would otherwise be auto-created with default
|
|
544
|
+
* mappings, defeating the mapping change);
|
|
545
|
+
* - any document failed to copy (the alias is left pointing at `fromIndex` so
|
|
546
|
+
* the live index stays whole — re-run after fixing the cause; `bulkUpsert` is
|
|
547
|
+
* idempotent by `_id`);
|
|
548
|
+
* - a paged hit is missing its `sort` value (`search_after` paging would be
|
|
549
|
+
* unsafe — indicates `sortField` is not a sortable field on the source).
|
|
469
550
|
*/
|
|
470
|
-
declare function reindex(
|
|
551
|
+
declare function reindex(input: ReindexJobInput): Promise<ReindexResult>;
|
|
471
552
|
//#endregion
|
|
472
|
-
export { type AliasAction, type BulkIndexFailure, type BulkIndexInput, type BulkIndexResult, type BulkOperation, type BulkResponse, ElasticsearchProvider, type ElasticsearchProviderConfig, type EsClientLike, type IndexConfig, type IndicesApi, PARTS_INDEX_ALIAS, type PartsDocument, type ReindexJobInput, type SearchHit, type SearchRequest, type SearchResponse, bulkUpsert, createIndex, dropIndex, ensureAliasedIndex, partsIndexConfig, readAliasIndex, reindex, swapAlias };
|
|
553
|
+
export { type AliasAction, type BulkIndexFailure, type BulkIndexInput, type BulkIndexResult, type BulkOperation, type BulkResponse, ElasticsearchProvider, type ElasticsearchProviderConfig, type EsClientLike, type IndexConfig, type IndicesApi, PARTS_INDEX_ALIAS, type PartsDocument, type ReindexJobInput, type ReindexResult, type SearchHit, type SearchRequest, type SearchResponse, bulkUpsert, createIndex, dropIndex, ensureAliasedIndex, partsIndexConfig, readAliasIndex, reindex, swapAlias };
|
|
473
554
|
//# sourceMappingURL=index.d.mts.map
|
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":"
|
|
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;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
|
|
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},name_ro:{type:`text`,index:!1},description1:{type:`text`,index:!1},description2:{type:`text`,index:!1},list_price_minor:{type:`long`},purchase_price_minor:{type:`long`},discount_group:{type:`keyword`},superseding_code:{type:`keyword`},core_charge_minor:{type:`long`},core_charge_currency:{type:`keyword`},core_charge_refundable:{type:`boolean`},core_charge_tax_class:{type:`keyword`},core_charge_tax_inclusive:{type:`boolean`},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}}function g(e){return e===void 0?0:typeof e==`number`?e:e.value}async function _(e){let{client:t,alias:n,fromIndex:r,toIndex:i,sortField:a=`doc_id`,batchSize:o=1e3,refresh:s,signal:c,reportProgress:l}=e,d=c===void 0?void 0:{signal:c};if(!await t.indices.exists({index:i},d))throw Error(`reindex: destination index "${i}" does not exist. Create it first with createIndex(client, { name: "${i}", config: { settings, mappings } }) so the new mapping is applied; otherwise ES would auto-create it with default text mappings.`);let f=[{[a]:{order:`asc`}}],p,m=0,_=0,v=0,y=[];for(;;){let e={index:r,size:o,sort:f,track_total_hits:!0,...p!==void 0&&{search_after:p}},n=await t.search(e,d),u=n.hits.hits;if(v=Math.max(v,g(n.hits.total)),u.length===0)break;let b=[];for(let e of u){if(e._id===void 0||e._source===void 0){_++;continue}b.push({id:e._id,doc:e._source})}if(b.length>0){let e=await h(t,{index:i,docs:b,...s!==void 0&&{refresh:s},...c!==void 0&&{signal:c}});m+=e.succeeded,y.push(...e.failures)}l&&await l({processed:m,total:Math.max(v,m)});let x=u[u.length-1];if(x===void 0)break;if(x.sort===void 0)throw Error(`reindex: hit "${x._id??`<unknown>`}" from "${r}" has no sort value; cannot page with search_after. Ensure "${a}" is a sortable keyword field.`);if(p=x.sort,u.length<o)break}if(y.length>0){let e=y.slice(0,3).map(e=>`${e.id}: ${e.type} (${e.reason})`).join(`; `);throw Error(`reindex: ${y.length} document(s) failed to copy from "${r}" to "${i}"; alias "${n}" NOT swapped (it still points at the source). Fix the cause and re-run (bulkUpsert is idempotent by _id). First failures: ${e}`)}return await u(t,{alias:n,toIndex:i,fromIndex:r}),{fromIndex:r,toIndex:i,processed:m,skipped:_}}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,_ 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 // 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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"}
|
|
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 /**\n * Romanian display name. Added for the 15-column carmaker feed drift\n * (`Description_RO`) — see commerce-demo phase 02. Stored; not indexed for\n * full-text per D5.\n */\n name_ro: 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\n // ---------------------------------------------------------------------------\n // commerce-demo fields (plan/commerce-demo PR1 — foundation for PR2/3/4/6/7).\n //\n // Money is stored in INTEGER MINOR UNITS (e.g. 20000 = €200.00) rather than\n // the legacy `*_eur` floats above. Derived purchase prices (`list × (1 − d)`,\n // PR4) and the core charge must not accrue float-rounding drift, so the new\n // money fields adopt minor units; the pre-existing `net_price_eur` /\n // `gross_price_eur` floats stay as-is (PLAN-ECOMMERCE D4 legacy, out of scope\n // to migrate here). All new fields are nullable: nothing populates them in PR1\n // — PR2's mapping-driven `buildRow` (purchase price), PR6 (superseding code),\n // and PR7 (core charge) fill them in.\n // ---------------------------------------------------------------------------\n\n /** Supplier list (catalogue) price, minor units — pre-discount. PR3/PR4. */\n list_price_minor: number | null\n /** OUR net purchase price, minor units — direct net, or `list × (1 − group%)`. PR4. */\n purchase_price_minor: number | null\n /** Supplier discount-group name; joined to `supplier_discount_group` for two-file derivation. PR4. */\n discount_group: string | null\n /** Manufacturer superseding / replacement code, if the feed carries one. PR6. */\n superseding_code: string | null\n /** Core charge (Korpusetasu) deposit amount, minor units. PR7. */\n core_charge_minor: number | null\n /** Core-charge currency (always `'EUR'` for v1, D9). PR7. */\n core_charge_currency: string | null\n /** Whether the core charge is a returnable deposit (default true). PR7. */\n core_charge_refundable: boolean | null\n /** Tax-NEUTRAL class reference for the core charge (D007 — future EU/US tax engine maps it). PR7. */\n core_charge_tax_class: string | null\n /** Whether the imported core-charge amount already includes tax (EU feeds = false). D007. PR7. */\n core_charge_tax_inclusive: boolean | null\n\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 name_ro: { type: 'text', index: false },\n description1: { type: 'text', index: false },\n description2: { type: 'text', index: false },\n // commerce-demo fields (PR1 foundation). Money in integer minor units,\n // typed `long` (not `integer`): int32 caps a single field at €21,474,836.47\n // in minor units, and a mis-scaled / aggregated feed value would overflow\n // and be rejected by ES — `long` removes that ceiling at negligible cost.\n // Codes/groups/tax-classes are keyword (exact-match joins + facets); the\n // refundable / tax-inclusive discriminators are boolean.\n list_price_minor: { type: 'long' },\n purchase_price_minor: { type: 'long' },\n discount_group: { type: 'keyword' },\n superseding_code: { type: 'keyword' },\n core_charge_minor: { type: 'long' },\n core_charge_currency: { type: 'keyword' },\n core_charge_refundable: { type: 'boolean' },\n core_charge_tax_class: { type: 'keyword' },\n core_charge_tax_inclusive: { type: 'boolean' },\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 (D6 alias-versioned reindex).\n *\n * Copies every document from a source physical index into a destination\n * physical index (which must already exist with the new mapping), then\n * atomically swaps the alias onto the destination. This is the mechanism that\n * makes a `PartsDocument` mapping change deployable without downtime:\n *\n * 1. `createIndex(client, { name: '<alias>_v<n+1>', config: { settings, mappings } })`\n * 2. `reindex({ client, alias, fromIndex: '<alias>_v<n>', toIndex: '<alias>_v<n+1>' })`\n * 3. (this function) `swapAlias` onto the destination on completion\n * 4. caller decides when to `dropIndex(fromIndex)` after a confidence window\n *\n * Paging uses `search_after` over a unique keyword sort field (`doc_id` for\n * parts) rather than `from`/`size` — `from` deep-pagination is O(from) per page\n * and capped by `max_result_window`, whereas `search_after` is constant-cost and\n * resumable (the cursor is the last hit's `sort` value, which a queue job can\n * persist in its `progress` payload to resume after a crash).\n *\n * **Safety contract:** the alias is swapped ONLY when every document copied\n * successfully. `bulkUpsert` is partial-success by design (one bad row doesn't\n * reject the batch), so this worker accumulates per-doc failures and THROWS\n * before swapping if any occurred — a half-copied index must never become the\n * live read target. The destination must also already exist (caller's\n * `createIndex`); writing to a missing index would let ES auto-create it with\n * inferred `text` mappings, silently defeating the mapping change — so we assert\n * its existence up front.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport { swapAlias } from './alias.js'\nimport { type BulkIndexFailure, bulkUpsert } from './bulk-index.js'\nimport type { EsClientLike } from './client.js'\n\nexport interface ReindexJobInput {\n client: EsClientLike\n /** Stable alias to swap onto `toIndex` once the copy completes. */\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 /**\n * Unique keyword field to sort by for stable `search_after` paging. MUST be\n * unique per document or pages will skip / duplicate rows. Defaults to the\n * parts index primary key `doc_id`.\n */\n sortField?: string\n /** Documents per page (default 1000). */\n batchSize?: number\n /**\n * Refresh policy forwarded to each `bulkUpsert`. Pass `'wait_for'` when the\n * caller needs the destination queryable the instant the alias swaps — the\n * default 30s `refresh_interval` would otherwise leave a brief window where\n * the swapped-in index returns no hits.\n */\n refresh?: boolean | 'wait_for'\n /** Optional abort signal threaded into every search + bulk call. */\n signal?: AbortSignal\n /** Optional progress reporter (provided by `@murumets-ee/queue` job context). */\n reportProgress?: (progress: { processed: number; total: number }) => Promise<void>\n}\n\nexport interface ReindexResult {\n fromIndex: string\n toIndex: string\n /** Documents successfully copied to the destination. */\n processed: number\n /** Source hits skipped because they lacked an `_id` or `_source`. */\n skipped: number\n}\n\n/** Normalize ES's `hits.total` (object-or-number, optional) to a count. */\nfunction resolveTotal(total: estypes.SearchTotalHits | number | undefined): number {\n if (total === undefined) return 0\n return typeof total === 'number' ? total : total.value\n}\n\n/**\n * Reindex `fromIndex` → `toIndex` and swap `alias` onto the destination.\n *\n * Returns the number of documents copied + skipped. Throws when:\n * - `toIndex` does not exist (would otherwise be auto-created with default\n * mappings, defeating the mapping change);\n * - any document failed to copy (the alias is left pointing at `fromIndex` so\n * the live index stays whole — re-run after fixing the cause; `bulkUpsert` is\n * idempotent by `_id`);\n * - a paged hit is missing its `sort` value (`search_after` paging would be\n * unsafe — indicates `sortField` is not a sortable field on the source).\n */\nexport async function reindex(input: ReindexJobInput): Promise<ReindexResult> {\n const {\n client,\n alias,\n fromIndex,\n toIndex,\n sortField = 'doc_id',\n batchSize = 1000,\n refresh,\n signal,\n reportProgress,\n } = input\n\n const options = signal !== undefined ? { signal } : undefined\n\n // The destination MUST pre-exist with the new mapping. Writing to a missing\n // index lets ES auto-create it with inferred `text` mappings (the exact\n // foot-gun `ensureAliasedIndex` refuses), then we'd swap the alias onto it.\n const destExists = await client.indices.exists({ index: toIndex }, options)\n if (!destExists) {\n throw new Error(\n `reindex: destination index \"${toIndex}\" does not exist. Create it first with ` +\n `createIndex(client, { name: \"${toIndex}\", config: { settings, mappings } }) so the ` +\n `new mapping is applied; otherwise ES would auto-create it with default text mappings.`,\n )\n }\n\n const sortClause: estypes.SortOptions = { [sortField]: { order: 'asc' } }\n const sort: estypes.Sort = [sortClause]\n\n let searchAfter: estypes.SortResults | undefined\n let processed = 0\n let skipped = 0\n let total = 0\n const failures: BulkIndexFailure[] = []\n\n while (true) {\n const request: estypes.SearchRequest = {\n index: fromIndex,\n size: batchSize,\n sort,\n track_total_hits: true,\n ...(searchAfter !== undefined && { search_after: searchAfter }),\n }\n const response = await client.search<Record<string, unknown>>(request, options)\n const hits = response.hits.hits\n total = Math.max(total, resolveTotal(response.hits.total))\n if (hits.length === 0) break\n\n const docs: Array<{ id: string; doc: Record<string, unknown> }> = []\n for (const hit of hits) {\n // A hit with no `_id` or no `_source` can't be copied; skip rather than\n // write a malformed doc. Both are optional on the real `SearchHit` type.\n if (hit._id === undefined || hit._source === undefined) {\n skipped++\n continue\n }\n docs.push({ id: hit._id, doc: hit._source })\n }\n if (docs.length > 0) {\n const result = await bulkUpsert<Record<string, unknown>>(client, {\n index: toIndex,\n docs,\n ...(refresh !== undefined && { refresh }),\n ...(signal !== undefined && { signal }),\n })\n processed += result.succeeded\n // bulkUpsert is partial-success — collect failures and decide after the\n // whole copy whether the alias may swap (see safety contract above).\n failures.push(...result.failures)\n }\n\n if (reportProgress) {\n // `total` can lag `processed` if the source grows mid-reindex; never\n // report a total below what we've already copied.\n await reportProgress({ processed, total: Math.max(total, processed) })\n }\n\n const last = hits[hits.length - 1]\n // Unreachable given the length check above, but keeps the cursor read\n // type-safe under `noUncheckedIndexedAccess`.\n if (last === undefined) break\n if (last.sort === undefined) {\n throw new Error(\n `reindex: hit \"${last._id ?? '<unknown>'}\" from \"${fromIndex}\" has no sort value; ` +\n `cannot page with search_after. Ensure \"${sortField}\" is a sortable keyword field.`,\n )\n }\n searchAfter = last.sort\n\n // A short page means we've drained the source — no further query needed.\n if (hits.length < batchSize) break\n }\n\n if (failures.length > 0) {\n // Do NOT swap onto a half-copied index. Surface a sample so the operator can\n // see the cause (typically a mapping rejection) without dumping every row.\n const sample = failures\n .slice(0, 3)\n .map((f) => `${f.id}: ${f.type} (${f.reason})`)\n .join('; ')\n throw new Error(\n `reindex: ${failures.length} document(s) failed to copy from \"${fromIndex}\" to ` +\n `\"${toIndex}\"; alias \"${alias}\" NOT swapped (it still points at the source). ` +\n `Fix the cause and re-run (bulkUpsert is idempotent by _id). First failures: ${sample}`,\n )\n }\n\n await swapAlias(client, { alias, toIndex, fromIndex })\n return { fromIndex, toIndex, processed, skipped }\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.29.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@elastic/elasticsearch": "^8.15.0",
|
|
17
|
-
"@murumets-ee/search": "0.
|
|
17
|
+
"@murumets-ee/search": "0.29.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/node": "^20.19.
|
|
21
|
-
"tsdown": "^0.
|
|
20
|
+
"@types/node": "^20.19.42",
|
|
21
|
+
"tsdown": "^0.22.2",
|
|
22
22
|
"typescript": "^5.7.3",
|
|
23
23
|
"vitest": "^2.1.8"
|
|
24
24
|
},
|