@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 +94 -0
- package/dist/index.d.mts +473 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +33 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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":"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"}
|
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
|
+
}
|