@murumets-ee/search-elasticsearch 0.12.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/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject
14
+ to the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license
39
+ for the software granted under these terms ends immediately. If your company
40
+ makes such a claim, your patent license ends immediately for work on behalf
41
+ of your company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from
46
+ you also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license
61
+ no later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your
64
+ licenses to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is
76
+ the software the licensor makes available under these terms, including any
77
+ portion of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or
85
+ the power to direct the management and policies of an entity (for example, by
86
+ voting right, contract, or otherwise). Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your
92
+ licenses.
93
+
94
+ **trademark** means trademarks, service marks, and similar rights.
@@ -0,0 +1,473 @@
1
+ import { SearchCapabilities, SearchInput, SearchProvider, SearchResult, SearchResultRow } from "@murumets-ee/search";
2
+ import { estypes } from "@elastic/elasticsearch";
3
+
4
+ //#region src/client.d.ts
5
+ /**
6
+ * Per-call transport options. The real ES v8 client accepts these as the
7
+ * SECOND argument to every API method (NOT inside the request params
8
+ * object). Putting `signal` on the params is silently ignored at runtime
9
+ * and the underlying HTTP request would not cancel — so this is the
10
+ * abort-signal binding for `search`, `bulk`, and friends.
11
+ *
12
+ * Structural subset of `@elastic/transport`'s `TransportRequestOptions` —
13
+ * we only thread through `signal`.
14
+ */
15
+ interface RequestOptions {
16
+ signal?: AbortSignal;
17
+ }
18
+ type AliasAction = estypes.IndicesUpdateAliasesAction;
19
+ type BulkOperation = estypes.BulkOperationContainer | Record<string, unknown>;
20
+ type BulkResponse = estypes.BulkResponse;
21
+ type SearchHit<TDoc> = estypes.SearchHit<TDoc>;
22
+ /**
23
+ * Search request shape — a structural subtype of `estypes.SearchRequest`.
24
+ * Field names + types match the real spec so an instance is assignable to
25
+ * the real `Client.search` parameter without casting. We expose only the
26
+ * fields the provider actually composes (`index`, `query`, `aggs`, etc.)
27
+ * rather than every option in the full ES request — keeping the surface
28
+ * small while staying honest about the shapes it does declare.
29
+ */
30
+ interface SearchRequest {
31
+ index: string;
32
+ query?: estypes.QueryDslQueryContainer;
33
+ size?: number;
34
+ from?: number;
35
+ sort?: estypes.Sort;
36
+ aggs?: Record<string, estypes.AggregationsAggregationContainer>;
37
+ /**
38
+ * Whether ES should compute the exact total. ES 8+ caps
39
+ * `hits.total.value` at 10000 with `relation: 'gte'` by default — set
40
+ * `true` to get exact counts. Provider sets this to true so callers
41
+ * never see capped totals masquerading as truth.
42
+ */
43
+ track_total_hits?: boolean | number;
44
+ }
45
+ /**
46
+ * Subset of `estypes.SearchResponseBody` declaring only the fields the
47
+ * provider reads. The real return type has additional REQUIRED fields
48
+ * (`timed_out`, `_shards`, etc.); we don't read them so we don't declare
49
+ * them. The real `Client.search` return is structurally assignable to
50
+ * this subset (it has everything we ask for, plus extras).
51
+ */
52
+ interface SearchResponse<TDoc> {
53
+ took: number;
54
+ hits: {
55
+ /**
56
+ * Optional in the real ES client — rare scroll / PIT response shapes
57
+ * can omit it. Provider treats `undefined` as `total: 0` rather than
58
+ * crashing on `total.value`.
59
+ */
60
+ total?: estypes.SearchTotalHits | number;
61
+ hits: ReadonlyArray<SearchHit<TDoc>>;
62
+ };
63
+ aggregations?: Record<string, estypes.AggregationsAggregate>;
64
+ }
65
+ /**
66
+ * Indices API surface — request types from `estypes` (so call sites
67
+ * cannot drift from the real spec); return types loosened to `unknown`
68
+ * for the methods whose return we don't read (`create`, `delete`,
69
+ * `updateAliases`).
70
+ */
71
+ interface IndicesApi {
72
+ exists(args: estypes.IndicesExistsRequest, options?: RequestOptions): Promise<boolean>;
73
+ create(args: estypes.IndicesCreateRequest, options?: RequestOptions): Promise<unknown>;
74
+ delete(args: estypes.IndicesDeleteRequest, options?: RequestOptions): Promise<unknown>;
75
+ getAlias(args: estypes.IndicesGetAliasRequest, options?: RequestOptions): Promise<estypes.IndicesGetAliasResponse>;
76
+ updateAliases(args: estypes.IndicesUpdateAliasesRequest, options?: RequestOptions): Promise<unknown>;
77
+ }
78
+ /**
79
+ * Composite client surface — what the package consumes. The real
80
+ * `@elastic/elasticsearch` `Client` is assignable to this interface
81
+ * (because it has all our methods + more); see the `assertEsClientLike`
82
+ * compile-time check at the bottom of this file.
83
+ */
84
+ interface EsClientLike {
85
+ readonly indices: IndicesApi;
86
+ bulk(args: estypes.BulkRequest, options?: RequestOptions): Promise<estypes.BulkResponse>;
87
+ search<TDoc>(args: estypes.SearchRequest, options?: RequestOptions): Promise<SearchResponse<TDoc>>;
88
+ }
89
+ //#endregion
90
+ //#region src/elasticsearch-provider.d.ts
91
+ interface ElasticsearchProviderConfig<TDoc> {
92
+ /** Logical resource name (registry key, route segment, audit tag). */
93
+ resource: string;
94
+ /**
95
+ * Permission resource for the framework `view` check. Defaults to
96
+ * `resource`. Override when the search resource and the entity-level
97
+ * permission diverge (e.g. resource `parts` gated on `commerce.parts:view`).
98
+ */
99
+ permissionResource?: string;
100
+ /** ES client (or structural equivalent for tests). */
101
+ client: EsClientLike;
102
+ /** Alias to query — the alias-versioned indirection from D6. */
103
+ indexAlias: string;
104
+ /**
105
+ * Whitelist of fields used for `term` / `prefix` matching. Each entry
106
+ * MUST be a `keyword`-typed field on the index mapping; ES `term` and
107
+ * `prefix` queries on `text` fields silently miss what they look like
108
+ * they should match.
109
+ *
110
+ * The first field is the primary; for `term` mode, all listed fields
111
+ * are queried with OR semantics.
112
+ */
113
+ searchableFields: readonly string[];
114
+ /**
115
+ * Whitelist of fields callers may filter on via `SearchInput.filters`
116
+ * (`?f.<field>=<value>`). Defaults to `searchableFields`. Anything
117
+ * outside this list is dropped — the single line of defense against the
118
+ * arbitrary-field-name injection vector documented on `FacetFilters`.
119
+ */
120
+ filterableFields?: readonly string[];
121
+ /**
122
+ * Whitelist of fields to compute facet buckets for. Subset of
123
+ * `filterableFields`. ES applies the same WHERE filter as the search
124
+ * query, so facet counts respect row-level scoping (D15).
125
+ */
126
+ facetFields?: readonly string[];
127
+ /** Per-facet bucket cap. Default 50 — matches typical UI panel size. */
128
+ facetSize?: number;
129
+ /**
130
+ * Override capabilities. Defaults: `{fullText:true, ranking:true,
131
+ * facets:true, fuzzy:false, prefix:true}`. Per D5 the parts consumer
132
+ * declares `{fullText:false, ranking:false, fuzzy:false}` so the search
133
+ * box doesn't render full-text affordances.
134
+ */
135
+ capabilities?: Partial<SearchCapabilities>;
136
+ /** Project an ES hit into a SearchResultRow (D14 — required, no default). */
137
+ transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow;
138
+ }
139
+ declare class ElasticsearchProvider<TDoc = Record<string, unknown>> implements SearchProvider {
140
+ readonly resource: string;
141
+ readonly permissionResource?: string;
142
+ readonly capabilities: SearchCapabilities;
143
+ private readonly client;
144
+ private readonly indexAlias;
145
+ private readonly searchableFields;
146
+ private readonly filterableFieldSet;
147
+ private readonly facetFields;
148
+ private readonly facetSize;
149
+ private readonly transform;
150
+ constructor(config: ElasticsearchProviderConfig<TDoc>);
151
+ search(input: SearchInput, signal: AbortSignal): Promise<SearchResult>;
152
+ /**
153
+ * Compose the ES query body. Combines the user query (mode-dependent)
154
+ * with the filter WHERE (whitelist-gated) under a single `bool.filter`
155
+ * — `filter` instead of `must` so docs aren't scored by the filters
156
+ * (faster, cacheable). The text query goes in `must` so scoring still
157
+ * applies to phrase mode.
158
+ *
159
+ * `searchableFields` is non-empty by construction (constructor throws
160
+ * on empty input), so `buildQueryClause` always returns a clause —
161
+ * `must.push` runs unconditionally.
162
+ */
163
+ private buildQuery;
164
+ private buildQueryClause;
165
+ /**
166
+ * Build `terms`-clause filters from caller-supplied `filters`. Field
167
+ * names not in the whitelist are silently dropped; multi-value entries
168
+ * use ES `terms` (OR within field), single values use `term`. The
169
+ * caller-facing semantic ("this field=these values") matches IlikeProvider.
170
+ */
171
+ private buildFilterClauses;
172
+ private buildAggs;
173
+ private collectFacets;
174
+ }
175
+ //#endregion
176
+ //#region src/parts-mapping.d.ts
177
+ /**
178
+ * Parts catalog mapping.
179
+ *
180
+ * Per D4 in PLAN-ECOMMERCE.md: one ES doc per `(code_normalized, supplier_id)`,
181
+ * `_id = "<code>__<supplier_id>"`. Consumers read this via the alias
182
+ * (`PARTS_INDEX_ALIAS`) and never address a physical index name directly so
183
+ * a mapping change can be deployed via D6 alias-versioned reindex.
184
+ *
185
+ * Per D5: search modes are `term` (exact-equality on `code_normalized`) and
186
+ * `prefix` (starts-with on `code_normalized`). No fuzzy / wildcard / multi-
187
+ * field boost. Description fields are stored, not indexed for full-text.
188
+ *
189
+ * The mapping is hardcoded for the PoC (per the §6 PoC track scope) — when
190
+ * a non-parts ES use case arrives, factor out a shared helper. Until then,
191
+ * one mapping shape avoids the abstraction cost of a generic mapping
192
+ * registry that has zero second consumer to validate it against.
193
+ */
194
+ /** Stable alias every reader queries through. Physical indices are versioned. */
195
+ declare const PARTS_INDEX_ALIAS = "parts";
196
+ /** Document shape written by the importer; read by ElasticsearchProvider. */
197
+ interface PartsDocument {
198
+ /** `<code_normalized>__<supplier_id>` — also the ES `_id`. */
199
+ doc_id: string;
200
+ /** Code as written by the supplier (`ME-A0000000000`). Display-only. */
201
+ code: string;
202
+ /** Bare manufacturer code, uppercased + dash/space-stripped. Used for term + prefix. */
203
+ code_normalized: string;
204
+ /** Brand UUID — keyed off `brand.id` per D24 (typed FK, not a string). */
205
+ brand_id: string;
206
+ /** Brand slug — used as the facet bucket label and for human-readable filters. */
207
+ brand_slug: string;
208
+ /** Supplier UUID. */
209
+ supplier_id: string;
210
+ /** Supplier customer-facing alias (D24 anti-disintermediation). NEVER `supplier.name`. */
211
+ supplier_display_name: string;
212
+ /** Net price in EUR (D9 single-currency). */
213
+ net_price_eur: number;
214
+ /** Gross price in EUR. Nullable when feed reports `'NA'`. */
215
+ gross_price_eur: number | null;
216
+ /** ISO 4217 currency code. Always `'EUR'` for v1 (D9); kept on the doc for forward-compat. */
217
+ currency: string;
218
+ /** Manufacturer barcode, if present. */
219
+ barcode: string | null;
220
+ /** Per-locale name fields. Stored; not indexed for full-text per D5. */
221
+ name_de: string | null;
222
+ name_en: string | null;
223
+ name_es: string | null;
224
+ name_fr: string | null;
225
+ name_it: string | null;
226
+ name_nl: string | null;
227
+ name_pt: string | null;
228
+ /** Free-form description fields from the feed. Stored; not indexed for full-text. */
229
+ description1: string | null;
230
+ description2: string | null;
231
+ /** Import-batch attribution — used for D7 source tagging and for stale-row cleanup. */
232
+ import_batch_id: string;
233
+ /** When this batch landed in ES. */
234
+ imported_at: string;
235
+ }
236
+ /**
237
+ * Index settings + mappings for the parts index.
238
+ *
239
+ * Settings tuned for ingestion throughput (refresh deferred during bulk
240
+ * imports — caller calls `refresh: 'wait_for'` once at the end of a batch
241
+ * if it needs the writes immediately visible).
242
+ */
243
+ declare const partsIndexConfig: {
244
+ readonly settings: {
245
+ readonly number_of_shards: 3;
246
+ readonly number_of_replicas: 1; /** Default 10000; bumped because admin pagination is bounded by mechanics, not by max_result_window. */
247
+ readonly max_result_window: 100000;
248
+ readonly refresh_interval: "30s";
249
+ };
250
+ readonly mappings: {
251
+ readonly properties: {
252
+ readonly doc_id: {
253
+ readonly type: "keyword";
254
+ };
255
+ readonly code: {
256
+ readonly type: "keyword";
257
+ };
258
+ readonly code_normalized: {
259
+ readonly type: "keyword";
260
+ };
261
+ readonly brand_id: {
262
+ readonly type: "keyword";
263
+ };
264
+ readonly brand_slug: {
265
+ readonly type: "keyword";
266
+ };
267
+ readonly supplier_id: {
268
+ readonly type: "keyword";
269
+ };
270
+ readonly supplier_display_name: {
271
+ readonly type: "keyword";
272
+ };
273
+ readonly net_price_eur: {
274
+ readonly type: "float";
275
+ };
276
+ readonly gross_price_eur: {
277
+ readonly type: "float";
278
+ };
279
+ readonly currency: {
280
+ readonly type: "keyword";
281
+ };
282
+ readonly barcode: {
283
+ readonly type: "keyword";
284
+ };
285
+ readonly name_de: {
286
+ readonly type: "text";
287
+ readonly index: false;
288
+ };
289
+ readonly name_en: {
290
+ readonly type: "text";
291
+ readonly index: false;
292
+ };
293
+ readonly name_es: {
294
+ readonly type: "text";
295
+ readonly index: false;
296
+ };
297
+ readonly name_fr: {
298
+ readonly type: "text";
299
+ readonly index: false;
300
+ };
301
+ readonly name_it: {
302
+ readonly type: "text";
303
+ readonly index: false;
304
+ };
305
+ readonly name_nl: {
306
+ readonly type: "text";
307
+ readonly index: false;
308
+ };
309
+ readonly name_pt: {
310
+ readonly type: "text";
311
+ readonly index: false;
312
+ };
313
+ readonly description1: {
314
+ readonly type: "text";
315
+ readonly index: false;
316
+ };
317
+ readonly description2: {
318
+ readonly type: "text";
319
+ readonly index: false;
320
+ };
321
+ readonly import_batch_id: {
322
+ readonly type: "keyword";
323
+ };
324
+ readonly imported_at: {
325
+ readonly type: "date";
326
+ };
327
+ };
328
+ };
329
+ };
330
+ //#endregion
331
+ //#region src/alias.d.ts
332
+ interface IndexConfig {
333
+ settings?: estypes.IndicesIndexSettings;
334
+ mappings?: estypes.MappingTypeMapping;
335
+ }
336
+ /**
337
+ * Create a physical index. Throws if the index already exists; callers that
338
+ * want idempotent creation should use {@link ensureAliasedIndex} or check
339
+ * `client.indices.exists` first.
340
+ */
341
+ declare function createIndex(client: EsClientLike, args: {
342
+ name: string;
343
+ config: IndexConfig;
344
+ }): Promise<void>;
345
+ /**
346
+ * Atomically point an alias at a new physical index.
347
+ *
348
+ * When `fromIndex` is provided, removes the alias from it in the same
349
+ * `updateAliases` call as the add — ES guarantees atomicity per docs.
350
+ * Readers see either the old index or the new index, never neither.
351
+ */
352
+ declare function swapAlias(client: EsClientLike, args: {
353
+ alias: string;
354
+ toIndex: string;
355
+ fromIndex?: string;
356
+ }): Promise<void>;
357
+ /** Delete a physical index. */
358
+ declare function dropIndex(client: EsClientLike, name: string): Promise<void>;
359
+ /**
360
+ * Ensure an alias exists pointing at a physical index. Used at boot /
361
+ * first deploy. Idempotent; safe to re-run.
362
+ *
363
+ * Behavior:
364
+ * - If the alias already exists → no-op, returns the current physical index name.
365
+ * - If a physical index named `<alias>_v1` exists but the alias does NOT
366
+ * point at it → swap the alias onto it (without dropping anything).
367
+ * - Otherwise → create `<alias>_v1` with the supplied config and point
368
+ * the alias at it.
369
+ *
370
+ * Returns the physical index name the alias is pointing at on completion.
371
+ */
372
+ declare function ensureAliasedIndex(client: EsClientLike, args: {
373
+ alias: string;
374
+ config: IndexConfig;
375
+ }): Promise<string>;
376
+ /**
377
+ * Resolve the physical index an alias currently points at, or null when
378
+ * the alias doesn't exist. Used by callers that want to know the current
379
+ * write target before issuing a reindex.
380
+ *
381
+ * **Multi-index aliases:** during a rolling reindex an alias can point at
382
+ * more than one physical index. This function returns the
383
+ * **lexicographically-LAST** name. With versioned naming
384
+ * (`<alias>_v1`, `<alias>_v2`, ...) that's the newest target — which
385
+ * matches what callers walking the alias-swap flow generally want
386
+ * (write to the new index, retire the old). Callers that need the
387
+ * complete set should use `client.indices.getAlias` directly.
388
+ */
389
+ declare function readAliasIndex(client: EsClientLike, alias: string): Promise<string | null>;
390
+ //#endregion
391
+ //#region src/bulk-index.d.ts
392
+ interface BulkIndexInput<TDoc> {
393
+ /** ES alias or physical index name to write to. */
394
+ index: string;
395
+ /** Documents to upsert. Each MUST carry the `_id` ES will use as primary key. */
396
+ docs: ReadonlyArray<{
397
+ id: string;
398
+ doc: TDoc;
399
+ }>;
400
+ /**
401
+ * Refresh policy. Default `false` (best for throughput; readers see writes
402
+ * after the next refresh interval). Tests and PoC search-after-import flows
403
+ * may set `'wait_for'` to read-your-writes.
404
+ */
405
+ refresh?: boolean | 'wait_for';
406
+ /**
407
+ * Optional abort signal. Threaded into the underlying `client.bulk` call
408
+ * via `RequestOptions` so a cancelled queue job actually stops the
409
+ * in-flight request rather than the worker waiting for a multi-second
410
+ * batch to complete before noticing the cancellation.
411
+ */
412
+ signal?: AbortSignal;
413
+ }
414
+ interface BulkIndexResult {
415
+ took: number;
416
+ /** Total documents the caller submitted. */
417
+ submitted: number;
418
+ /** Documents the cluster acknowledged successfully. */
419
+ succeeded: number;
420
+ /**
421
+ * Per-doc failures. The bulk operation is partial-success by design — one
422
+ * bad row doesn't reject the rest of the batch. Caller handles these
423
+ * (importer aggregates into `ErrorTracker` patterns).
424
+ */
425
+ failures: ReadonlyArray<BulkIndexFailure>;
426
+ }
427
+ interface BulkIndexFailure {
428
+ id: string;
429
+ /** ES error type, e.g. `mapper_parsing_exception`. */
430
+ type: string;
431
+ /** Human-readable reason from ES. */
432
+ reason: string;
433
+ /** HTTP-style status: 4xx for client mistakes, 5xx for cluster issues. */
434
+ status: number;
435
+ }
436
+ /**
437
+ * Bulk-index a batch of documents. Returns per-doc success / failure detail
438
+ * — does NOT throw on partial failure. The importer is responsible for
439
+ * deciding when accumulated failures cross a threshold that should fail
440
+ * the import_run.
441
+ */
442
+ declare function bulkUpsert<TDoc>(client: EsClientLike, args: BulkIndexInput<TDoc>): Promise<BulkIndexResult>;
443
+ //#endregion
444
+ //#region src/reindex-worker.d.ts
445
+ interface ReindexJobInput {
446
+ client: EsClientLike;
447
+ alias: string;
448
+ /** Source physical index. */
449
+ fromIndex: string;
450
+ /** Destination physical index (must already exist with the new mapping). */
451
+ toIndex: string;
452
+ /** Optional progress reporter (provided by `@murumets-ee/queue` job context). */
453
+ reportProgress?: (progress: {
454
+ processed: number;
455
+ total: number;
456
+ }) => Promise<void>;
457
+ }
458
+ /**
459
+ * **STUB — throws unconditionally.** PoC scope per PLAN-ECOMMERCE.md §6.0.
460
+ * A future implementation will drive a resumable reindex from `fromIndex`
461
+ * to `toIndex` and atomically swap the alias on completion.
462
+ *
463
+ * When implemented, the canonical flow is:
464
+ * 1. Read in batches via the ES scroll / search_after API (resumable —
465
+ * the cursor lives in the queue job's `progress` payload).
466
+ * 2. For each batch, write via `bulkUpsert` to `toIndex`.
467
+ * 3. On completion, `swapAlias(client, alias, fromIndex, toIndex)`.
468
+ * 4. Caller decides when to `dropIndex(fromIndex)` after a confidence window.
469
+ */
470
+ declare function reindex(_input: ReindexJobInput): Promise<never>;
471
+ //#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 };
473
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/client.ts","../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{SearchError as e,SearchErrorCodes as t}from"@murumets-ee/search";const n={fullText:!0,ranking:!0,facets:!0,fuzzy:!1,prefix:!0};var r=class{resource;permissionResource;capabilities;client;indexAlias;searchableFields;filterableFieldSet;facetFields;facetSize;transform;constructor(e){if(e.searchableFields.length===0)throw Error(`ElasticsearchProvider '${e.resource}' requires at least one searchable field`);let t=e.filterableFields??e.searchableFields,r=e.facetFields??[];for(let n of r)if(!t.includes(n))throw Error(`ElasticsearchProvider '${e.resource}' facet field '${n}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`);this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.capabilities={...n,...e.capabilities??{}},this.client=e.client,this.indexAlias=e.indexAlias,this.searchableFields=e.searchableFields,this.filterableFieldSet=new Set(t),this.facetFields=r,this.facetSize=e.facetSize??50,this.transform=e.transform}async search(n,r){if(r.aborted)throw new e(`Search aborted`,504,t.Timeout);let i={index:this.indexAlias,query:this.buildQuery(n),size:n.limit,from:n.offset,track_total_hits:!0};this.facetFields.length>0&&(i.aggs=this.buildAggs());let a=Date.now(),s;try{s=await this.client.search(i,{signal:r})}catch(n){throw r.aborted?new e(`Search aborted`,504,t.Timeout,{cause:n}):n}let c=Date.now()-a,l=o(s.hits.total),u=[];for(let e of s.hits.hits)e._id===void 0||e._source===void 0||u.push(this.transform(e._source,e._score??null,e._id));return{rows:u,total:l,facets:this.collectFacets(s.aggregations??{}),durationMs:c}}buildQuery(e){let t=[this.buildQueryClause(e)],n=this.buildFilterClauses(e.filters);return{bool:{must:t,...n.length>0&&{filter:n}}}}buildQueryClause(e){let t=i[e.mode];return a(this.searchableFields,n=>({[t]:{[n]:e.query}}))}buildFilterClauses(e){let t=[];for(let[n,r]of Object.entries(e))if(this.filterableFieldSet.has(n)&&r.length!==0)if(r.length===1){let e=r[0];if(e===void 0)continue;t.push({term:{[n]:e}})}else t.push({terms:{[n]:[...r]}});return t}buildAggs(){let e={};for(let t of this.facetFields)e[t]={terms:{field:t,size:this.facetSize}};return e}collectFacets(e){let t=[];for(let n of this.facetFields){let r=e[n];if(!r)continue;let i=r.buckets;if(!Array.isArray(i))continue;let a=i.map(e=>{let t=e;return{value:String(t.key),count:t.doc_count}});t.push({field:n,buckets:a})}return t}};const i={term:`term`,prefix:`prefix`,phrase:`match_phrase`};function a(e,t){let n=e[0];if(n===void 0)throw Error(`unreachable: searchableFields cannot be empty (constructor enforces)`);return e.length===1?t(n):{bool:{should:e.map(t),minimum_should_match:1}}}function o(e){return e===void 0?0:typeof e==`number`?e:e.value}const s=`parts`,c={settings:{number_of_shards:3,number_of_replicas:1,max_result_window:1e5,refresh_interval:`30s`},mappings:{properties:{doc_id:{type:`keyword`},code:{type:`keyword`},code_normalized:{type:`keyword`},brand_id:{type:`keyword`},brand_slug:{type:`keyword`},supplier_id:{type:`keyword`},supplier_display_name:{type:`keyword`},net_price_eur:{type:`float`},gross_price_eur:{type:`float`},currency:{type:`keyword`},barcode:{type:`keyword`},name_de:{type:`text`,index:!1},name_en:{type:`text`,index:!1},name_es:{type:`text`,index:!1},name_fr:{type:`text`,index:!1},name_it:{type:`text`,index:!1},name_nl:{type:`text`,index:!1},name_pt:{type:`text`,index:!1},description1:{type:`text`,index:!1},description2:{type:`text`,index:!1},import_batch_id:{type:`keyword`},imported_at:{type:`date`}}}};async function l(e,t){let{name:n,config:r}=t;await e.indices.create({index:n,...r.settings!==void 0&&{settings:r.settings},...r.mappings!==void 0&&{mappings:r.mappings}})}async function u(e,t){let{alias:n,toIndex:r,fromIndex:i}=t,a=[];i!==void 0&&i!==r&&a.push({remove:{index:i,alias:n}}),a.push({add:{index:r,alias:n}}),await e.indices.updateAliases({actions:a})}async function d(e,t){await e.indices.delete({index:t})}async function f(e,t){let{alias:n,config:r}=t,i=await p(e,n);if(i!==null)return i;let a=`${n}_v1`;return await e.indices.exists({index:a})||await l(e,{name:a,config:r}),await u(e,{alias:n,toIndex:a}),a}async function p(e,t){try{let n=await e.indices.getAlias({name:t}),r=Object.keys(n);return r.length===0?null:[...r].sort()[r.length-1]??null}catch(e){if(m(e))return null;throw e}}function m(e){if(typeof e!=`object`||!e)return!1;let t=e;if(t.meta?.statusCode!==404)return!1;let n=t.body?.error?.type;return n===`index_not_found_exception`||n===`alias_not_found_exception`}async function h(e,t){let{index:n,docs:r,refresh:i,signal:a}=t;if(r.length===0)return{took:0,submitted:0,succeeded:0,failures:[]};let o=[];for(let{id:e,doc:t}of r)o.push({index:{_index:n,_id:e}}),o.push(t);let s=await e.bulk({operations:o,...i!==void 0&&{refresh:i}},...a===void 0?[]:[{signal:a}]);if(!s.errors)return{took:s.took,submitted:r.length,succeeded:r.length,failures:[]};let c=[];for(let e of s.items){let t=e.index;!t||t.error===void 0||c.push({id:t._id??`<unknown>`,type:t.error.type,reason:t.error.reason??`<no reason>`,status:t.status})}return{took:s.took,submitted:r.length,succeeded:r.length-c.length,failures:c}}async function g(e){throw Error(`reindex() is not implemented — deferred per PLAN-ECOMMERCE.md §6 PoC track. The PoC import flow writes through bulkUpsert into the live aliased index. When mapping-changing reindex is needed, finish this implementation per the JSDoc on this function.`)}export{r as ElasticsearchProvider,s as PARTS_INDEX_ALIAS,h as bulkUpsert,l as createIndex,d as dropIndex,f as ensureAliasedIndex,c as partsIndexConfig,p as readAliasIndex,g as reindex,u as swapAlias};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["typedBuckets"],"sources":["../src/elasticsearch-provider.ts","../src/parts-mapping.ts","../src/alias.ts","../src/bulk-index.ts","../src/reindex-worker.ts"],"sourcesContent":["/**\n * `ElasticsearchProvider` — implements the generic SearchProvider contract\n * from `@murumets-ee/search`, backed by an ES client (or anything matching\n * the `EsClientLike` structural interface).\n *\n * Per D5 in PLAN-ECOMMERCE.md the parts use case is term + prefix only —\n * but this class is generic over any ES use case, so it implements all\n * three modes (`term`, `prefix`, `phrase`). Consumers gate UI behavior via\n * the configurable `capabilities` flag.\n *\n * Per D14 the consumer supplies the `transform` projection from raw doc\n * → SearchResultRow; per D15 facets are subject to the same WHERE filter as\n * the underlying query (we just compute aggs in the same search call, so\n * the row-level scoping applies automatically).\n *\n * Per the binding constraint on `FacetFilters` (PR 1): field names are\n * arbitrary user strings — the `filterableFields` whitelist is the only\n * line of defense against ES query injection. The constructor throws when\n * the whitelist is empty AND filters were configured to be acceptable.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport type {\n FacetAggregation,\n FacetBucket,\n FacetFilters,\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport type { EsClientLike, SearchRequest, SearchResponse } from './client.js'\n\nexport interface ElasticsearchProviderConfig<TDoc> {\n /** Logical resource name (registry key, route segment, audit tag). */\n resource: string\n /**\n * Permission resource for the framework `view` check. Defaults to\n * `resource`. Override when the search resource and the entity-level\n * permission diverge (e.g. resource `parts` gated on `commerce.parts:view`).\n */\n permissionResource?: string\n /** ES client (or structural equivalent for tests). */\n client: EsClientLike\n /** Alias to query — the alias-versioned indirection from D6. */\n indexAlias: string\n /**\n * Whitelist of fields used for `term` / `prefix` matching. Each entry\n * MUST be a `keyword`-typed field on the index mapping; ES `term` and\n * `prefix` queries on `text` fields silently miss what they look like\n * they should match.\n *\n * The first field is the primary; for `term` mode, all listed fields\n * are queried with OR semantics.\n */\n searchableFields: readonly string[]\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`\n * (`?f.<field>=<value>`). Defaults to `searchableFields`. Anything\n * outside this list is dropped — the single line of defense against the\n * arbitrary-field-name injection vector documented on `FacetFilters`.\n */\n filterableFields?: readonly string[]\n /**\n * Whitelist of fields to compute facet buckets for. Subset of\n * `filterableFields`. ES applies the same WHERE filter as the search\n * query, so facet counts respect row-level scoping (D15).\n */\n facetFields?: readonly string[]\n /** Per-facet bucket cap. Default 50 — matches typical UI panel size. */\n facetSize?: number\n /**\n * Override capabilities. Defaults: `{fullText:true, ranking:true,\n * facets:true, fuzzy:false, prefix:true}`. Per D5 the parts consumer\n * declares `{fullText:false, ranking:false, fuzzy:false}` so the search\n * box doesn't render full-text affordances.\n */\n capabilities?: Partial<SearchCapabilities>\n /** Project an ES hit into a SearchResultRow (D14 — required, no default). */\n transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n}\n\nconst DEFAULT_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: true,\n fuzzy: false,\n prefix: true,\n}\n\nconst DEFAULT_FACET_SIZE = 50\n\nexport class ElasticsearchProvider<TDoc = Record<string, unknown>>\n implements SearchProvider\n{\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities: SearchCapabilities\n\n private readonly client: EsClientLike\n private readonly indexAlias: string\n private readonly searchableFields: readonly string[]\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly facetFields: readonly string[]\n private readonly facetSize: number\n private readonly transform: (doc: TDoc, score: number | null, id: string) => SearchResultRow\n\n constructor(config: ElasticsearchProviderConfig<TDoc>) {\n if (config.searchableFields.length === 0) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' requires at least one searchable field`,\n )\n }\n const filterable = config.filterableFields ?? config.searchableFields\n const facets = config.facetFields ?? []\n for (const facet of facets) {\n if (!filterable.includes(facet)) {\n throw new Error(\n `ElasticsearchProvider '${config.resource}' facet field '${facet}' is not in filterableFields — facet aggregations must be filterable so the panel toggles are coherent`,\n )\n }\n }\n\n this.resource = config.resource\n if (config.permissionResource !== undefined) {\n this.permissionResource = config.permissionResource\n }\n this.capabilities = { ...DEFAULT_CAPABILITIES, ...(config.capabilities ?? {}) }\n this.client = config.client\n this.indexAlias = config.indexAlias\n this.searchableFields = config.searchableFields\n this.filterableFieldSet = new Set(filterable)\n this.facetFields = facets\n this.facetSize = config.facetSize ?? DEFAULT_FACET_SIZE\n this.transform = config.transform\n }\n\n async search(input: SearchInput, signal: AbortSignal): Promise<SearchResult> {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n\n const request: SearchRequest = {\n index: this.indexAlias,\n query: this.buildQuery(input),\n size: input.limit,\n from: input.offset,\n // ES 8+ caps total at 10000 by default — opt into exact counts so\n // the displayed total isn't a ceiling masquerading as truth.\n track_total_hits: true,\n }\n if (this.facetFields.length > 0) {\n request.aggs = this.buildAggs()\n }\n\n const start = Date.now()\n let response: SearchResponse<TDoc>\n try {\n // signal goes into the SECOND options arg — the real ES v8 client\n // ignores `signal` placed on the request params (see RequestOptions\n // JSDoc on client.ts).\n response = await this.client.search<TDoc>(request, { signal })\n } catch (error: unknown) {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout, { cause: error })\n }\n throw error\n }\n const durationMs = Date.now() - start\n\n const total = readTotal(response.hits.total)\n\n // Real ES `SearchHit` has `_id?: Id`, `_source?: TDocument`, `_score?:\n // number | null` — all optional. Skip hits missing the fields we need\n // rather than crashing the whole search; the missing-field shapes are\n // rare (require explicit `_source: false` or specific PIT/scroll\n // responses) but the type system surfaces them honestly now.\n const rows: SearchResultRow[] = []\n for (const hit of response.hits.hits) {\n if (hit._id === undefined || hit._source === undefined) continue\n rows.push(this.transform(hit._source, hit._score ?? null, hit._id))\n }\n\n return {\n rows,\n total,\n facets: this.collectFacets(response.aggregations ?? {}),\n durationMs,\n }\n }\n\n /**\n * Compose the ES query body. Combines the user query (mode-dependent)\n * with the filter WHERE (whitelist-gated) under a single `bool.filter`\n * — `filter` instead of `must` so docs aren't scored by the filters\n * (faster, cacheable). The text query goes in `must` so scoring still\n * applies to phrase mode.\n *\n * `searchableFields` is non-empty by construction (constructor throws\n * on empty input), so `buildQueryClause` always returns a clause —\n * `must.push` runs unconditionally.\n */\n private buildQuery(input: SearchInput): estypes.QueryDslQueryContainer {\n const must: estypes.QueryDslQueryContainer[] = [this.buildQueryClause(input)]\n const filter = this.buildFilterClauses(input.filters)\n return {\n bool: {\n must,\n ...(filter.length > 0 && { filter }),\n },\n }\n }\n\n private buildQueryClause(input: SearchInput): estypes.QueryDslQueryContainer {\n const verb = QUERY_VERB_BY_MODE[input.mode]\n return wrapShouldOr(this.searchableFields, (field) => ({\n [verb]: { [field]: input.query },\n }))\n }\n\n /**\n * Build `terms`-clause filters from caller-supplied `filters`. Field\n * names not in the whitelist are silently dropped; multi-value entries\n * use ES `terms` (OR within field), single values use `term`. The\n * caller-facing semantic (\"this field=these values\") matches IlikeProvider.\n */\n private buildFilterClauses(filters: FacetFilters): estypes.QueryDslQueryContainer[] {\n const out: estypes.QueryDslQueryContainer[] = []\n for (const [field, values] of Object.entries(filters)) {\n if (!this.filterableFieldSet.has(field)) continue\n if (values.length === 0) continue\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) continue\n out.push({ term: { [field]: single } })\n } else {\n out.push({ terms: { [field]: [...values] } })\n }\n }\n return out\n }\n\n private buildAggs(): Record<string, estypes.AggregationsAggregationContainer> {\n const aggs: Record<string, estypes.AggregationsAggregationContainer> = {}\n for (const field of this.facetFields) {\n aggs[field] = { terms: { field, size: this.facetSize } }\n }\n return aggs\n }\n\n private collectFacets(\n aggregations: Record<string, estypes.AggregationsAggregate>,\n ): readonly FacetAggregation[] {\n const out: FacetAggregation[] = []\n for (const field of this.facetFields) {\n const agg = aggregations[field]\n if (!agg) continue\n // We only emit terms aggregations (see buildAggs), which always\n // return array-form buckets. `AggregationsAggregate` is a union of\n // ~70 aggregate shapes; structurally probe for the bucket array\n // rather than narrowing the entire union. ES's `AggregationsBuckets`\n // can also be `Record<string, T>` (filter-style aggs) — those are\n // config drift here, ignore rather than misrepresent.\n const rawBuckets = (agg as { buckets?: unknown }).buckets\n if (!Array.isArray(rawBuckets)) continue\n const typedBuckets = rawBuckets as ReadonlyArray<unknown>\n const buckets: FacetBucket[] = typedBuckets.map((b) => {\n // ES `FieldValue` officially permits `any`; in practice a terms\n // aggregation only emits `string | number | boolean`. Narrow to\n // that here so the cast doesn't smuggle `any` through (and the\n // package's 100% type-coverage floor stays a real signal).\n const bucket = b as { key: string | number | boolean; doc_count: number }\n return {\n // Coerce so the FacetBucket.value: string contract holds.\n value: String(bucket.key),\n count: bucket.doc_count,\n }\n })\n out.push({ field, buckets })\n }\n return out\n }\n}\n\nconst QUERY_VERB_BY_MODE: Record<SearchInput['mode'], 'term' | 'prefix' | 'match_phrase'> = {\n term: 'term',\n prefix: 'prefix',\n phrase: 'match_phrase',\n}\n\n/**\n * Build a single-clause query (when `fields` has one entry) or a\n * `bool.should` OR over N field-clauses with `minimum_should_match: 1`.\n * Caller passes a per-field clause builder; this collapses the\n * single-vs-multi shape repetition that used to live in three branches.\n */\nfunction wrapShouldOr(\n fields: readonly string[],\n perField: (field: string) => estypes.QueryDslQueryContainer,\n): estypes.QueryDslQueryContainer {\n // Constructor enforces `fields.length >= 1`, so `fields[0]` is always\n // defined. Throw on the impossible-state path so a future regression\n // lands loudly rather than producing a silent `match_all`.\n const onlyField = fields[0]\n if (onlyField === undefined) {\n throw new Error('unreachable: searchableFields cannot be empty (constructor enforces)')\n }\n if (fields.length === 1) return perField(onlyField)\n return {\n bool: {\n should: fields.map(perField),\n minimum_should_match: 1,\n },\n }\n}\n\n/**\n * ES total can be `{ value, relation } | number | undefined`. The\n * `undefined` shape is rare (some scroll/PIT responses) but real — so\n * treat it as `0` rather than reading `.value` on undefined and\n * crashing.\n */\nfunction readTotal(total: SearchResponse<unknown>['hits']['total']): number {\n if (total === undefined) return 0\n if (typeof total === 'number') return total\n return total.value\n}\n","/**\n * Parts catalog mapping.\n *\n * Per D4 in PLAN-ECOMMERCE.md: one ES doc per `(code_normalized, supplier_id)`,\n * `_id = \"<code>__<supplier_id>\"`. Consumers read this via the alias\n * (`PARTS_INDEX_ALIAS`) and never address a physical index name directly so\n * a mapping change can be deployed via D6 alias-versioned reindex.\n *\n * Per D5: search modes are `term` (exact-equality on `code_normalized`) and\n * `prefix` (starts-with on `code_normalized`). No fuzzy / wildcard / multi-\n * field boost. Description fields are stored, not indexed for full-text.\n *\n * The mapping is hardcoded for the PoC (per the §6 PoC track scope) — when\n * a non-parts ES use case arrives, factor out a shared helper. Until then,\n * one mapping shape avoids the abstraction cost of a generic mapping\n * registry that has zero second consumer to validate it against.\n */\n\n/** Stable alias every reader queries through. Physical indices are versioned. */\nexport const PARTS_INDEX_ALIAS = 'parts'\n\n/** Document shape written by the importer; read by ElasticsearchProvider. */\nexport interface PartsDocument {\n /** `<code_normalized>__<supplier_id>` — also the ES `_id`. */\n doc_id: string\n /** Code as written by the supplier (`ME-A0000000000`). Display-only. */\n code: string\n /** Bare manufacturer code, uppercased + dash/space-stripped. Used for term + prefix. */\n code_normalized: string\n /** Brand UUID — keyed off `brand.id` per D24 (typed FK, not a string). */\n brand_id: string\n /** Brand slug — used as the facet bucket label and for human-readable filters. */\n brand_slug: string\n /** Supplier UUID. */\n supplier_id: string\n /** Supplier customer-facing alias (D24 anti-disintermediation). NEVER `supplier.name`. */\n supplier_display_name: string\n /** Net price in EUR (D9 single-currency). */\n net_price_eur: number\n /** Gross price in EUR. Nullable when feed reports `'NA'`. */\n gross_price_eur: number | null\n /** ISO 4217 currency code. Always `'EUR'` for v1 (D9); kept on the doc for forward-compat. */\n currency: string\n /** Manufacturer barcode, if present. */\n barcode: string | null\n /** Per-locale name fields. Stored; not indexed for full-text per D5. */\n name_de: string | null\n name_en: string | null\n name_es: string | null\n name_fr: string | null\n name_it: string | null\n name_nl: string | null\n name_pt: string | null\n /** Free-form description fields from the feed. Stored; not indexed for full-text. */\n description1: string | null\n description2: string | null\n /** Import-batch attribution — used for D7 source tagging and for stale-row cleanup. */\n import_batch_id: string\n /** When this batch landed in ES. */\n imported_at: string\n}\n\n/**\n * Index settings + mappings for the parts index.\n *\n * Settings tuned for ingestion throughput (refresh deferred during bulk\n * imports — caller calls `refresh: 'wait_for'` once at the end of a batch\n * if it needs the writes immediately visible).\n */\nexport const partsIndexConfig = {\n settings: {\n number_of_shards: 3,\n number_of_replicas: 1,\n /** Default 10000; bumped because admin pagination is bounded by mechanics, not by max_result_window. */\n max_result_window: 100000,\n refresh_interval: '30s',\n },\n mappings: {\n properties: {\n doc_id: { type: 'keyword' },\n code: { type: 'keyword' },\n code_normalized: { type: 'keyword' },\n brand_id: { type: 'keyword' },\n brand_slug: { type: 'keyword' },\n supplier_id: { type: 'keyword' },\n supplier_display_name: { type: 'keyword' },\n net_price_eur: { type: 'float' },\n gross_price_eur: { type: 'float' },\n currency: { type: 'keyword' },\n barcode: { type: 'keyword' },\n // Per-locale display names. `text` so they're searchable if a future\n // editorial-overlay use case wires full-text on them; the parts\n // ElasticsearchProvider does NOT search across these per D5.\n name_de: { type: 'text', index: false },\n name_en: { type: 'text', index: false },\n name_es: { type: 'text', index: false },\n name_fr: { type: 'text', index: false },\n name_it: { type: 'text', index: false },\n name_nl: { type: 'text', index: false },\n name_pt: { type: 'text', index: false },\n description1: { type: 'text', index: false },\n description2: { type: 'text', index: false },\n import_batch_id: { type: 'keyword' },\n imported_at: { type: 'date' },\n },\n },\n} as const\n","/**\n * Alias-versioned index management (D6).\n *\n * Every consumer queries through a stable alias; physical indices are\n * versioned. Mapping changes ship via:\n * 1. `createIndex(client, { name: <alias>_<version>, settings, mappings })`\n * 2. (consumer back-fills the new index with current data — typically\n * via the importer's bulkUpsert)\n * 3. `swapAlias(client, alias, fromIndex, toIndex)` — atomic\n * 4. `dropIndex(client, fromIndex)` after a confidence window\n *\n * `ensureAliasedIndex` covers the first-time-deploy case — when the alias\n * doesn't exist, create the first physical index and point the alias at\n * it. Idempotent: re-running on an already-created alias is a no-op.\n */\n\nimport type { estypes } from '@elastic/elasticsearch'\nimport type { AliasAction, EsClientLike } from './client.js'\n\nexport interface IndexConfig {\n settings?: estypes.IndicesIndexSettings\n mappings?: estypes.MappingTypeMapping\n}\n\n/**\n * Create a physical index. Throws if the index already exists; callers that\n * want idempotent creation should use {@link ensureAliasedIndex} or check\n * `client.indices.exists` first.\n */\nexport async function createIndex(\n client: EsClientLike,\n args: { name: string; config: IndexConfig },\n): Promise<void> {\n const { name, config } = args\n await client.indices.create({\n index: name,\n ...(config.settings !== undefined && { settings: config.settings }),\n ...(config.mappings !== undefined && { mappings: config.mappings }),\n })\n}\n\n/**\n * Atomically point an alias at a new physical index.\n *\n * When `fromIndex` is provided, removes the alias from it in the same\n * `updateAliases` call as the add — ES guarantees atomicity per docs.\n * Readers see either the old index or the new index, never neither.\n */\nexport async function swapAlias(\n client: EsClientLike,\n args: { alias: string; toIndex: string; fromIndex?: string },\n): Promise<void> {\n const { alias, toIndex, fromIndex } = args\n const actions: AliasAction[] = []\n if (fromIndex !== undefined && fromIndex !== toIndex) {\n actions.push({ remove: { index: fromIndex, alias } })\n }\n actions.push({ add: { index: toIndex, alias } })\n await client.indices.updateAliases({ actions })\n}\n\n/** Delete a physical index. */\nexport async function dropIndex(client: EsClientLike, name: string): Promise<void> {\n await client.indices.delete({ index: name })\n}\n\n/**\n * Ensure an alias exists pointing at a physical index. Used at boot /\n * first deploy. Idempotent; safe to re-run.\n *\n * Behavior:\n * - If the alias already exists → no-op, returns the current physical index name.\n * - If a physical index named `<alias>_v1` exists but the alias does NOT\n * point at it → swap the alias onto it (without dropping anything).\n * - Otherwise → create `<alias>_v1` with the supplied config and point\n * the alias at it.\n *\n * Returns the physical index name the alias is pointing at on completion.\n */\nexport async function ensureAliasedIndex(\n client: EsClientLike,\n args: { alias: string; config: IndexConfig },\n): Promise<string> {\n const { alias, config } = args\n\n // Try to read the existing alias mapping; ES throws when nothing matches,\n // so swallow that single case (and only that one) and treat as \"no alias yet\".\n const aliased = await readAliasIndex(client, alias)\n if (aliased !== null) return aliased\n\n const initialName = `${alias}_v1`\n const initialExists = await client.indices.exists({ index: initialName })\n if (!initialExists) {\n await createIndex(client, { name: initialName, config })\n }\n await swapAlias(client, { alias, toIndex: initialName })\n return initialName\n}\n\n/**\n * Resolve the physical index an alias currently points at, or null when\n * the alias doesn't exist. Used by callers that want to know the current\n * write target before issuing a reindex.\n *\n * **Multi-index aliases:** during a rolling reindex an alias can point at\n * more than one physical index. This function returns the\n * **lexicographically-LAST** name. With versioned naming\n * (`<alias>_v1`, `<alias>_v2`, ...) that's the newest target — which\n * matches what callers walking the alias-swap flow generally want\n * (write to the new index, retire the old). Callers that need the\n * complete set should use `client.indices.getAlias` directly.\n */\nexport async function readAliasIndex(\n client: EsClientLike,\n alias: string,\n): Promise<string | null> {\n try {\n const response = await client.indices.getAlias({ name: alias })\n const indices = Object.keys(response)\n if (indices.length === 0) return null\n // Lexicographic-last: with `<alias>_v<N>` names, this picks the highest N.\n return [...indices].sort()[indices.length - 1] ?? null\n } catch (error: unknown) {\n if (isNotFoundError(error)) return null\n throw error\n }\n}\n\n/**\n * Recognises the structured 404 the ES client throws when an alias/index\n * doesn't exist. We REQUIRE BOTH `meta.statusCode === 404` AND a\n * recognized ES `body.error.type` — a bare HTTP 404 from a misconfigured\n * proxy / load balancer (Cloudflare, ALB) would otherwise be silently\n * misread as \"alias missing\", and `ensureAliasedIndex` would then go on\n * to create a duplicate index. Bare 404s without ES context bubble up as\n * real errors so the operator sees the proxy issue rather than corrupted\n * index state.\n */\nfunction isNotFoundError(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) return false\n const e = error as {\n meta?: { statusCode?: number }\n body?: { error?: { type?: string } }\n }\n if (e.meta?.statusCode !== 404) return false\n const errorType = e.body?.error?.type\n return errorType === 'index_not_found_exception' || errorType === 'alias_not_found_exception'\n}\n","/**\n * Bulk upsert helper used by the imports package (PR 7) to write feed rows\n * into ES. Each doc keys on its `_id` so re-running the same import batch\n * is idempotent (the second run overwrites instead of duplicating).\n *\n * Per D21 in PLAN-ECOMMERCE.md: feed-driven writes bypass entity hooks /\n * AdminClient — that is the sanctioned escape, not a backdoor. This helper\n * is the bulk-write surface the importer is allowed to use; nothing else\n * should call it directly. Per-batch audit logging happens in the importer.\n */\n\nimport type { BulkOperation, BulkResponse, EsClientLike } from './client.js'\n\nexport interface BulkIndexInput<TDoc> {\n /** ES alias or physical index name to write to. */\n index: string\n /** Documents to upsert. Each MUST carry the `_id` ES will use as primary key. */\n docs: ReadonlyArray<{ id: string; doc: TDoc }>\n /**\n * Refresh policy. Default `false` (best for throughput; readers see writes\n * after the next refresh interval). Tests and PoC search-after-import flows\n * may set `'wait_for'` to read-your-writes.\n */\n refresh?: boolean | 'wait_for'\n /**\n * Optional abort signal. Threaded into the underlying `client.bulk` call\n * via `RequestOptions` so a cancelled queue job actually stops the\n * in-flight request rather than the worker waiting for a multi-second\n * batch to complete before noticing the cancellation.\n */\n signal?: AbortSignal\n}\n\nexport interface BulkIndexResult {\n took: number\n /** Total documents the caller submitted. */\n submitted: number\n /** Documents the cluster acknowledged successfully. */\n succeeded: number\n /**\n * Per-doc failures. The bulk operation is partial-success by design — one\n * bad row doesn't reject the rest of the batch. Caller handles these\n * (importer aggregates into `ErrorTracker` patterns).\n */\n failures: ReadonlyArray<BulkIndexFailure>\n}\n\nexport interface BulkIndexFailure {\n id: string\n /** ES error type, e.g. `mapper_parsing_exception`. */\n type: string\n /** Human-readable reason from ES. */\n reason: string\n /** HTTP-style status: 4xx for client mistakes, 5xx for cluster issues. */\n status: number\n}\n\n/**\n * Bulk-index a batch of documents. Returns per-doc success / failure detail\n * — does NOT throw on partial failure. The importer is responsible for\n * deciding when accumulated failures cross a threshold that should fail\n * the import_run.\n */\nexport async function bulkUpsert<TDoc>(\n client: EsClientLike,\n args: BulkIndexInput<TDoc>,\n): Promise<BulkIndexResult> {\n const { index, docs, refresh, signal } = args\n if (docs.length === 0) {\n return { took: 0, submitted: 0, succeeded: 0, failures: [] }\n }\n\n const operations: BulkOperation[] = []\n for (const { id, doc } of docs) {\n operations.push({ index: { _index: index, _id: id } })\n operations.push(doc as unknown as Record<string, unknown>)\n }\n\n const response: BulkResponse = await client.bulk(\n {\n operations,\n ...(refresh !== undefined && { refresh }),\n },\n // signal lives in the second options arg per the real ES v8 client\n // contract (see RequestOptions JSDoc on client.ts). Conditionally\n // pass the options object so we don't introduce undefined into the\n // signature when caller didn't supply one.\n ...(signal !== undefined ? [{ signal }] : []),\n )\n\n if (!response.errors) {\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length,\n failures: [],\n }\n }\n\n const failures: BulkIndexFailure[] = []\n for (const item of response.items) {\n // We only ever emit `index` actions, so the response item key is\n // stable. Read it directly — the previous Object.values fallback\n // promised tolerance the code couldn't actually deliver (ES legitimately\n // echoing two keys would still break). If a future call site needs\n // create/update/delete, extend the action match here.\n const result = item.index\n if (!result || result.error === undefined) continue\n failures.push({\n // Real ES `BulkResponseItem._id: string | null | undefined`. Cover\n // both null and undefined with the same fallback.\n id: result._id ?? '<unknown>',\n type: result.error.type,\n // Real ES `ErrorCauseKeys.reason?: string | null` — both null and\n // missing are legitimate; fall back to a placeholder rather than\n // letting `null` typed as `string` smuggle through.\n reason: result.error.reason ?? '<no reason>',\n status: result.status,\n })\n }\n\n return {\n took: response.took,\n submitted: docs.length,\n succeeded: docs.length - failures.length,\n failures,\n }\n}\n","/**\n * Reindex worker — STUB.\n *\n * Per the §6 PoC track scope in PLAN-ECOMMERCE.md, the reindex worker is\n * deferred. The PoC import path writes through `bulkUpsert` directly into\n * the live alias's underlying index, which is enough for the\n * single-supplier validation and won't survive a mapping change. Once the\n * PoC validates, the full reindex worker (queue-driven, resumable,\n * progress-reporting via PR 0) lands here.\n *\n * The stub function exists so consumers can wire up the call shape now\n * and get a loud error instead of a silent no-op when they invoke it\n * pre-completion.\n */\n\nimport type { EsClientLike } from './client.js'\n\nexport interface ReindexJobInput {\n client: EsClientLike\n alias: string\n /** Source physical index. */\n fromIndex: string\n /** Destination physical index (must already exist with the new mapping). */\n toIndex: string\n /** Optional progress reporter (provided by `@murumets-ee/queue` job context). */\n reportProgress?: (progress: { processed: number; total: number }) => Promise<void>\n}\n\n/**\n * **STUB — throws unconditionally.** PoC scope per PLAN-ECOMMERCE.md §6.0.\n * A future implementation will drive a resumable reindex from `fromIndex`\n * to `toIndex` and atomically swap the alias on completion.\n *\n * When implemented, the canonical flow is:\n * 1. Read in batches via the ES scroll / search_after API (resumable —\n * the cursor lives in the queue job's `progress` payload).\n * 2. For each batch, write via `bulkUpsert` to `toIndex`.\n * 3. On completion, `swapAlias(client, alias, fromIndex, toIndex)`.\n * 4. Caller decides when to `dropIndex(fromIndex)` after a confidence window.\n */\nexport async function reindex(_input: ReindexJobInput): Promise<never> {\n throw new Error(\n 'reindex() is not implemented — deferred per PLAN-ECOMMERCE.md §6 PoC track. ' +\n 'The PoC import flow writes through bulkUpsert into the live aliased index. ' +\n 'When mapping-changing reindex is needed, finish this implementation per the ' +\n 'JSDoc on this function.',\n )\n}\n"],"mappings":"wEAoFA,MAAM,EAA2C,CAC/C,SAAU,GACV,QAAS,GACT,OAAQ,GACR,MAAO,GACP,OAAQ,GACT,CAID,IAAa,EAAb,KAEA,CACE,SACA,mBACA,aAEA,OACA,WACA,iBACA,mBACA,YACA,UACA,UAEA,YAAY,EAA2C,CACrD,GAAI,EAAO,iBAAiB,SAAW,EACrC,MAAU,MACR,0BAA0B,EAAO,SAAS,0CAC3C,CAEH,IAAM,EAAa,EAAO,kBAAoB,EAAO,iBAC/C,EAAS,EAAO,aAAe,EAAE,CACvC,IAAK,IAAM,KAAS,EAClB,GAAI,CAAC,EAAW,SAAS,EAAM,CAC7B,MAAU,MACR,0BAA0B,EAAO,SAAS,iBAAiB,EAAM,wGAClE,CAIL,KAAK,SAAW,EAAO,SACnB,EAAO,qBAAuB,IAAA,KAChC,KAAK,mBAAqB,EAAO,oBAEnC,KAAK,aAAe,CAAE,GAAG,EAAsB,GAAI,EAAO,cAAgB,EAAE,CAAG,CAC/E,KAAK,OAAS,EAAO,OACrB,KAAK,WAAa,EAAO,WACzB,KAAK,iBAAmB,EAAO,iBAC/B,KAAK,mBAAqB,IAAI,IAAI,EAAW,CAC7C,KAAK,YAAc,EACnB,KAAK,UAAY,EAAO,WAAa,GACrC,KAAK,UAAY,EAAO,UAG1B,MAAM,OAAO,EAAoB,EAA4C,CAC3E,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,QAAQ,CAGxE,IAAM,EAAyB,CAC7B,MAAO,KAAK,WACZ,MAAO,KAAK,WAAW,EAAM,CAC7B,KAAM,EAAM,MACZ,KAAM,EAAM,OAGZ,iBAAkB,GACnB,CACG,KAAK,YAAY,OAAS,IAC5B,EAAQ,KAAO,KAAK,WAAW,EAGjC,IAAM,EAAQ,KAAK,KAAK,CACpB,EACJ,GAAI,CAIF,EAAW,MAAM,KAAK,OAAO,OAAa,EAAS,CAAE,SAAQ,CAAC,OACvD,EAAgB,CAIvB,MAHI,EAAO,QACH,IAAI,EAAY,iBAAkB,IAAK,EAAiB,QAAS,CAAE,MAAO,EAAO,CAAC,CAEpF,EAER,IAAM,EAAa,KAAK,KAAK,CAAG,EAE1B,EAAQ,EAAU,EAAS,KAAK,MAAM,CAOtC,EAA0B,EAAE,CAClC,IAAK,IAAM,KAAO,EAAS,KAAK,KAC1B,EAAI,MAAQ,IAAA,IAAa,EAAI,UAAY,IAAA,IAC7C,EAAK,KAAK,KAAK,UAAU,EAAI,QAAS,EAAI,QAAU,KAAM,EAAI,IAAI,CAAC,CAGrE,MAAO,CACL,OACA,QACA,OAAQ,KAAK,cAAc,EAAS,cAAgB,EAAE,CAAC,CACvD,aACD,CAcH,WAAmB,EAAoD,CACrE,IAAM,EAAyC,CAAC,KAAK,iBAAiB,EAAM,CAAC,CACvE,EAAS,KAAK,mBAAmB,EAAM,QAAQ,CACrD,MAAO,CACL,KAAM,CACJ,OACA,GAAI,EAAO,OAAS,GAAK,CAAE,SAAQ,CACpC,CACF,CAGH,iBAAyB,EAAoD,CAC3E,IAAM,EAAO,EAAmB,EAAM,MACtC,OAAO,EAAa,KAAK,iBAAmB,IAAW,EACpD,GAAO,EAAG,GAAQ,EAAM,MAAO,CACjC,EAAE,CASL,mBAA2B,EAAyD,CAClF,IAAM,EAAwC,EAAE,CAChD,IAAK,GAAM,CAAC,EAAO,KAAW,OAAO,QAAQ,EAAQ,CAC9C,QAAK,mBAAmB,IAAI,EAAM,EACnC,EAAO,SAAW,EACtB,GAAI,EAAO,SAAW,EAAG,CACvB,IAAM,EAAS,EAAO,GACtB,GAAI,IAAW,IAAA,GAAW,SAC1B,EAAI,KAAK,CAAE,KAAM,EAAG,GAAQ,EAAQ,CAAE,CAAC,MAEvC,EAAI,KAAK,CAAE,MAAO,EAAG,GAAQ,CAAC,GAAG,EAAO,CAAE,CAAE,CAAC,CAGjD,OAAO,EAGT,WAA8E,CAC5E,IAAM,EAAiE,EAAE,CACzE,IAAK,IAAM,KAAS,KAAK,YACvB,EAAK,GAAS,CAAE,MAAO,CAAE,QAAO,KAAM,KAAK,UAAW,CAAE,CAE1D,OAAO,EAGT,cACE,EAC6B,CAC7B,IAAM,EAA0B,EAAE,CAClC,IAAK,IAAM,KAAS,KAAK,YAAa,CACpC,IAAM,EAAM,EAAa,GACzB,GAAI,CAAC,EAAK,SAOV,IAAM,EAAc,EAA8B,QAClD,GAAI,CAAC,MAAM,QAAQ,EAAW,CAAE,SAEhC,IAAM,EAAyBA,EAAa,IAAK,GAAM,CAKrD,IAAM,EAAS,EACf,MAAO,CAEL,MAAO,OAAO,EAAO,IAAI,CACzB,MAAO,EAAO,UACf,EACD,CACF,EAAI,KAAK,CAAE,QAAO,UAAS,CAAC,CAE9B,OAAO,IAIX,MAAM,EAAsF,CAC1F,KAAM,OACN,OAAQ,SACR,OAAQ,eACT,CAQD,SAAS,EACP,EACA,EACgC,CAIhC,IAAM,EAAY,EAAO,GACzB,GAAI,IAAc,IAAA,GAChB,MAAU,MAAM,uEAAuE,CAGzF,OADI,EAAO,SAAW,EAAU,EAAS,EAAU,CAC5C,CACL,KAAM,CACJ,OAAQ,EAAO,IAAI,EAAS,CAC5B,qBAAsB,EACvB,CACF,CASH,SAAS,EAAU,EAAyD,CAG1E,OAFI,IAAU,IAAA,GAAkB,EAC5B,OAAO,GAAU,SAAiB,EAC/B,EAAM,MCpTf,MAAa,EAAoB,QAkDpB,EAAmB,CAC9B,SAAU,CACR,iBAAkB,EAClB,mBAAoB,EAEpB,kBAAmB,IACnB,iBAAkB,MACnB,CACD,SAAU,CACR,WAAY,CACV,OAAQ,CAAE,KAAM,UAAW,CAC3B,KAAM,CAAE,KAAM,UAAW,CACzB,gBAAiB,CAAE,KAAM,UAAW,CACpC,SAAU,CAAE,KAAM,UAAW,CAC7B,WAAY,CAAE,KAAM,UAAW,CAC/B,YAAa,CAAE,KAAM,UAAW,CAChC,sBAAuB,CAAE,KAAM,UAAW,CAC1C,cAAe,CAAE,KAAM,QAAS,CAChC,gBAAiB,CAAE,KAAM,QAAS,CAClC,SAAU,CAAE,KAAM,UAAW,CAC7B,QAAS,CAAE,KAAM,UAAW,CAI5B,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,QAAS,CAAE,KAAM,OAAQ,MAAO,GAAO,CACvC,aAAc,CAAE,KAAM,OAAQ,MAAO,GAAO,CAC5C,aAAc,CAAE,KAAM,OAAQ,MAAO,GAAO,CAC5C,gBAAiB,CAAE,KAAM,UAAW,CACpC,YAAa,CAAE,KAAM,OAAQ,CAC9B,CACF,CACF,CC7ED,eAAsB,EACpB,EACA,EACe,CACf,GAAM,CAAE,OAAM,UAAW,EACzB,MAAM,EAAO,QAAQ,OAAO,CAC1B,MAAO,EACP,GAAI,EAAO,WAAa,IAAA,IAAa,CAAE,SAAU,EAAO,SAAU,CAClE,GAAI,EAAO,WAAa,IAAA,IAAa,CAAE,SAAU,EAAO,SAAU,CACnE,CAAC,CAUJ,eAAsB,EACpB,EACA,EACe,CACf,GAAM,CAAE,QAAO,UAAS,aAAc,EAChC,EAAyB,EAAE,CAC7B,IAAc,IAAA,IAAa,IAAc,GAC3C,EAAQ,KAAK,CAAE,OAAQ,CAAE,MAAO,EAAW,QAAO,CAAE,CAAC,CAEvD,EAAQ,KAAK,CAAE,IAAK,CAAE,MAAO,EAAS,QAAO,CAAE,CAAC,CAChD,MAAM,EAAO,QAAQ,cAAc,CAAE,UAAS,CAAC,CAIjD,eAAsB,EAAU,EAAsB,EAA6B,CACjF,MAAM,EAAO,QAAQ,OAAO,CAAE,MAAO,EAAM,CAAC,CAgB9C,eAAsB,EACpB,EACA,EACiB,CACjB,GAAM,CAAE,QAAO,UAAW,EAIpB,EAAU,MAAM,EAAe,EAAQ,EAAM,CACnD,GAAI,IAAY,KAAM,OAAO,EAE7B,IAAM,EAAc,GAAG,EAAM,KAM7B,OAJK,MADuB,EAAO,QAAQ,OAAO,CAAE,MAAO,EAAa,CAAC,EAEvE,MAAM,EAAY,EAAQ,CAAE,KAAM,EAAa,SAAQ,CAAC,CAE1D,MAAM,EAAU,EAAQ,CAAE,QAAO,QAAS,EAAa,CAAC,CACjD,EAgBT,eAAsB,EACpB,EACA,EACwB,CACxB,GAAI,CACF,IAAM,EAAW,MAAM,EAAO,QAAQ,SAAS,CAAE,KAAM,EAAO,CAAC,CACzD,EAAU,OAAO,KAAK,EAAS,CAGrC,OAFI,EAAQ,SAAW,EAAU,KAE1B,CAAC,GAAG,EAAQ,CAAC,MAAM,CAAC,EAAQ,OAAS,IAAM,WAC3C,EAAgB,CACvB,GAAI,EAAgB,EAAM,CAAE,OAAO,KACnC,MAAM,GAcV,SAAS,EAAgB,EAAyB,CAChD,GAAI,OAAO,GAAU,WAAY,EAAgB,MAAO,GACxD,IAAM,EAAI,EAIV,GAAI,EAAE,MAAM,aAAe,IAAK,MAAO,GACvC,IAAM,EAAY,EAAE,MAAM,OAAO,KACjC,OAAO,IAAc,6BAA+B,IAAc,4BCnFpE,eAAsB,EACpB,EACA,EAC0B,CAC1B,GAAM,CAAE,QAAO,OAAM,UAAS,UAAW,EACzC,GAAI,EAAK,SAAW,EAClB,MAAO,CAAE,KAAM,EAAG,UAAW,EAAG,UAAW,EAAG,SAAU,EAAE,CAAE,CAG9D,IAAM,EAA8B,EAAE,CACtC,IAAK,GAAM,CAAE,KAAI,SAAS,EACxB,EAAW,KAAK,CAAE,MAAO,CAAE,OAAQ,EAAO,IAAK,EAAI,CAAE,CAAC,CACtD,EAAW,KAAK,EAA0C,CAG5D,IAAM,EAAyB,MAAM,EAAO,KAC1C,CACE,aACA,GAAI,IAAY,IAAA,IAAa,CAAE,UAAS,CACzC,CAKD,GAAI,IAAW,IAAA,GAA2B,EAAE,CAAjB,CAAC,CAAE,SAAQ,CAAC,CACxC,CAED,GAAI,CAAC,EAAS,OACZ,MAAO,CACL,KAAM,EAAS,KACf,UAAW,EAAK,OAChB,UAAW,EAAK,OAChB,SAAU,EAAE,CACb,CAGH,IAAM,EAA+B,EAAE,CACvC,IAAK,IAAM,KAAQ,EAAS,MAAO,CAMjC,IAAM,EAAS,EAAK,MAChB,CAAC,GAAU,EAAO,QAAU,IAAA,IAChC,EAAS,KAAK,CAGZ,GAAI,EAAO,KAAO,YAClB,KAAM,EAAO,MAAM,KAInB,OAAQ,EAAO,MAAM,QAAU,cAC/B,OAAQ,EAAO,OAChB,CAAC,CAGJ,MAAO,CACL,KAAM,EAAS,KACf,UAAW,EAAK,OAChB,UAAW,EAAK,OAAS,EAAS,OAClC,WACD,CCtFH,eAAsB,EAAQ,EAAyC,CACrE,MAAU,MACR,6PAID"}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@murumets-ee/search-elasticsearch",
3
+ "version": "0.12.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "import": "./dist/index.mjs"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@elastic/elasticsearch": "^8.15.0",
17
+ "@murumets-ee/search": "0.12.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.19.39",
21
+ "tsdown": "^0.21.10",
22
+ "typescript": "^5.7.3",
23
+ "vitest": "^2.1.8"
24
+ },
25
+ "typeCoverage": {
26
+ "atLeast": 100
27
+ },
28
+ "scripts": {
29
+ "build": "tsdown",
30
+ "dev": "tsdown --watch",
31
+ "test": "vitest"
32
+ }
33
+ }