@endora-commerce/mod-search 0.100.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/backend/cli/reindex.d.ts +4 -0
  4. package/dist/backend/cli/reindex.d.ts.map +1 -0
  5. package/dist/backend/cli/reindex.js +32 -0
  6. package/dist/backend/cli/reindex.js.map +1 -0
  7. package/dist/backend/entities/search-phrase-record.entity.d.ts +40 -0
  8. package/dist/backend/entities/search-phrase-record.entity.d.ts.map +1 -0
  9. package/dist/backend/entities/search-phrase-record.entity.js +85 -0
  10. package/dist/backend/entities/search-phrase-record.entity.js.map +1 -0
  11. package/dist/backend/index.d.ts +87 -0
  12. package/dist/backend/index.d.ts.map +1 -0
  13. package/dist/backend/index.js +153 -0
  14. package/dist/backend/index.js.map +1 -0
  15. package/dist/backend/plugin.d.ts +133 -0
  16. package/dist/backend/plugin.d.ts.map +1 -0
  17. package/dist/backend/plugin.js +250 -0
  18. package/dist/backend/plugin.js.map +1 -0
  19. package/dist/backend/routes.admin.d.ts +28 -0
  20. package/dist/backend/routes.admin.d.ts.map +1 -0
  21. package/dist/backend/routes.admin.js +33 -0
  22. package/dist/backend/routes.admin.js.map +1 -0
  23. package/dist/backend/routes.public.d.ts +22 -0
  24. package/dist/backend/routes.public.d.ts.map +1 -0
  25. package/dist/backend/routes.public.js +192 -0
  26. package/dist/backend/routes.public.js.map +1 -0
  27. package/dist/backend/services/embedder-config-resolver.d.ts +25 -0
  28. package/dist/backend/services/embedder-config-resolver.d.ts.map +1 -0
  29. package/dist/backend/services/embedder-config-resolver.js +47 -0
  30. package/dist/backend/services/embedder-config-resolver.js.map +1 -0
  31. package/dist/backend/services/llm-toggle.service.d.ts +50 -0
  32. package/dist/backend/services/llm-toggle.service.d.ts.map +1 -0
  33. package/dist/backend/services/llm-toggle.service.js +78 -0
  34. package/dist/backend/services/llm-toggle.service.js.map +1 -0
  35. package/dist/backend/services/search-event-subscriber.d.ts +125 -0
  36. package/dist/backend/services/search-event-subscriber.d.ts.map +1 -0
  37. package/dist/backend/services/search-event-subscriber.js +113 -0
  38. package/dist/backend/services/search-event-subscriber.js.map +1 -0
  39. package/dist/backend/services/search-indexer.d.ts +346 -0
  40. package/dist/backend/services/search-indexer.d.ts.map +1 -0
  41. package/dist/backend/services/search-indexer.js +670 -0
  42. package/dist/backend/services/search-indexer.js.map +1 -0
  43. package/dist/backend/services/search-phrase-recorder.service.d.ts +25 -0
  44. package/dist/backend/services/search-phrase-recorder.service.d.ts.map +1 -0
  45. package/dist/backend/services/search-phrase-recorder.service.js +89 -0
  46. package/dist/backend/services/search-phrase-recorder.service.js.map +1 -0
  47. package/dist/backend/services/search-query-port.d.ts +29 -0
  48. package/dist/backend/services/search-query-port.d.ts.map +1 -0
  49. package/dist/backend/services/search-query-port.js +20 -0
  50. package/dist/backend/services/search-query-port.js.map +1 -0
  51. package/dist/backend/services/search-query.service.d.ts +205 -0
  52. package/dist/backend/services/search-query.service.d.ts.map +1 -0
  53. package/dist/backend/services/search-query.service.js +346 -0
  54. package/dist/backend/services/search-query.service.js.map +1 -0
  55. package/dist/backend/services/search-reindex-worker.d.ts +27 -0
  56. package/dist/backend/services/search-reindex-worker.d.ts.map +1 -0
  57. package/dist/backend/services/search-reindex-worker.js +13 -0
  58. package/dist/backend/services/search-reindex-worker.js.map +1 -0
  59. package/dist/backend/services/search-suggest.service.d.ts +53 -0
  60. package/dist/backend/services/search-suggest.service.d.ts.map +1 -0
  61. package/dist/backend/services/search-suggest.service.js +64 -0
  62. package/dist/backend/services/search-suggest.service.js.map +1 -0
  63. package/dist/backend/services/suggestion-pricing-enricher.d.ts +62 -0
  64. package/dist/backend/services/suggestion-pricing-enricher.d.ts.map +1 -0
  65. package/dist/backend/services/suggestion-pricing-enricher.js +41 -0
  66. package/dist/backend/services/suggestion-pricing-enricher.js.map +1 -0
  67. package/dist/manifest.d.ts +263 -0
  68. package/dist/manifest.d.ts.map +1 -0
  69. package/dist/manifest.js +300 -0
  70. package/dist/manifest.js.map +1 -0
  71. package/dist/migrations/20260501T123145_search_phrase_records_init.d.ts +13 -0
  72. package/dist/migrations/20260501T123145_search_phrase_records_init.d.ts.map +1 -0
  73. package/dist/migrations/20260501T123145_search_phrase_records_init.js +40 -0
  74. package/dist/migrations/20260501T123145_search_phrase_records_init.js.map +1 -0
  75. package/dist/migrations/index.d.ts +27 -0
  76. package/dist/migrations/index.d.ts.map +1 -0
  77. package/dist/migrations/index.js +29 -0
  78. package/dist/migrations/index.js.map +1 -0
  79. package/docs/search.md +284 -0
  80. package/i18n/en.json +10 -0
  81. package/i18n/pl.json +10 -0
  82. package/package.json +72 -0
@@ -0,0 +1,346 @@
1
+ import { Meilisearch, MeilisearchApiError } from 'meilisearch';
2
+ import { ERROR_CODES, isProductVisibleTo, listingPriceMoney, } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { encodeCursor, decodeCursor } from '@endora-commerce/platform/http';
5
+ import { indexUidFor, } from './search-indexer.js';
6
+ export class SearchQueryService {
7
+ products;
8
+ attributeRead;
9
+ listingPrices;
10
+ organizations;
11
+ client;
12
+ constructor(
13
+ /**
14
+ * Issue #153 — `catalog`'s product rows, over the port, where an
15
+ * `EntityManager` and `em.find(Product, …)` used to be.
16
+ *
17
+ * The `EntityManager` could not leave while `catalog/plugin.ts` built this
18
+ * class itself: a second module's composition decided what this
19
+ * constructor took. It resolves `searchQueryPort` now, so the hydration
20
+ * below asks `catalog` for its rows and answers 503 `MODULE_DISABLED` when
21
+ * `catalog` is off — which is right, because `search`'s manifest declares
22
+ * `catalog` and an index over a catalogue that is gone has nothing to
23
+ * hydrate against.
24
+ */
25
+ products,
26
+ /**
27
+ * Feature 061 — the catalog's composed attribute read model (Principle I:
28
+ * filterable validation reads the view, not the catalog entity). Feature
29
+ * 075, Phase C — typed as the published port; `catalog`'s own service
30
+ * satisfies it structurally, so its construction of this class is unchanged.
31
+ */
32
+ attributeRead, options = {},
33
+ /**
34
+ * Issue #132 — the pricing engine, through the `pricingService` port. The
35
+ * hydration below exists so that Postgres, not the index, decides what a
36
+ * hit shows; the price it hydrates now comes from the engine for the same
37
+ * reason.
38
+ */
39
+ listingPrices,
40
+ /**
41
+ * `organizations`' read model, for the customer group a group-targeted
42
+ * price list is selected by. The module already resolves this port for the
43
+ * typeahead popup, which has priced per buyer since feature 075 — the
44
+ * result feed under the popup did not, so one search box quoted two
45
+ * different figures for one product depending on whether the buyer stopped
46
+ * at the suggestions or pressed Enter.
47
+ *
48
+ * Optional only in the signature, and never reached by an anonymous query.
49
+ */
50
+ organizations) {
51
+ this.products = products;
52
+ this.attributeRead = attributeRead;
53
+ this.listingPrices = listingPrices;
54
+ this.organizations = organizations;
55
+ const host = options.meilisearchHost ??
56
+ process.env['MEILISEARCH_URL'] ??
57
+ 'http://localhost:7700';
58
+ const apiKey = options.meilisearchApiKey ??
59
+ process.env['MEILISEARCH_API_KEY'] ??
60
+ undefined;
61
+ this.client = new Meilisearch(apiKey ? { host, apiKey } : { host });
62
+ }
63
+ async listProducts(params, ctx) {
64
+ const channel = ctx.resolvedChannel;
65
+ // Validate filter keys against the live composed attribute views so that an
66
+ // unfilterable attribute returns the same 400 the Postgres path returns
67
+ // (FR-005, T043). The Meilisearch index only enforces shape, not policy.
68
+ if (params.attributeFilters) {
69
+ const keys = Object.keys(params.attributeFilters);
70
+ if (keys.length > 0) {
71
+ if (!this.attributeRead) {
72
+ throw new Error('SearchQueryService: the catalog attribute read port is not wired — attribute-filter validation is unavailable.');
73
+ }
74
+ const attrs = await this.attributeRead.listAll();
75
+ const byKey = new Map(attrs.map((a) => [a.key, a]));
76
+ for (const k of keys) {
77
+ const a = byKey.get(k);
78
+ if (!a || !a.isFilterable) {
79
+ throw new HttpError(400, ERROR_CODES.FILTER_NOT_ALLOWED, `Attribute "${k}" is not filterable.`, [{ path: `filter[attr.${k}]`, issue: 'attribute is not filterable' }]);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ const indexUid = indexUidFor(channel);
85
+ const index = this.client.index(indexUid);
86
+ const offset = decodeOffsetCursor(params.cursor) ?? 0;
87
+ const filter = buildFilterExpression(params);
88
+ const sort = buildSort(params.sort);
89
+ let response;
90
+ try {
91
+ response = await index.search(params.q ?? '', {
92
+ offset,
93
+ // Over-fetch by one to detect hasMore.
94
+ limit: params.limit + 1,
95
+ ...(filter.length > 0 ? { filter } : {}),
96
+ ...(sort.length > 0 ? { sort } : {}),
97
+ attributesToRetrieve: [
98
+ 'id',
99
+ 'sku',
100
+ 'name',
101
+ 'slug',
102
+ 'type',
103
+ 'categorySlugs',
104
+ 'primaryAssetUrl',
105
+ ],
106
+ });
107
+ }
108
+ catch (err) {
109
+ // Reserved-fallback (R-08): `searchQueryPort` converts this into the
110
+ // `index-unavailable` arm and `catalog`'s listing re-runs the request
111
+ // through the Postgres path. Re-throw so the owner's port can decide.
112
+ //
113
+ // Issue #287 — but say *which* failure it was. Every failure of this
114
+ // call used to be wrapped as "unavailable", so a healthy engine
115
+ // refusing the query (`invalid_search_sort`, because the index carried
116
+ // no sortable attributes; `index_not_found`; a bad API key) was filed
117
+ // under the one heading an operator reads as transient. The degrade is
118
+ // still a degrade — a public catalogue must not 503 because search is
119
+ // unhappy — but a refusal is a defect on this side of the wire, so it
120
+ // names the engine's own error code and gets reported by this module
121
+ // rather than only by whoever happened to call it.
122
+ throw searchBackendFailure(err, indexUid);
123
+ }
124
+ const hits = response.hits.slice(0, params.limit);
125
+ const hasMore = response.hits.length > params.limit;
126
+ const nextCursor = hasMore ? encodeOffsetCursor(offset + params.limit) : null;
127
+ if (hits.length === 0) {
128
+ return {
129
+ data: [],
130
+ pagination: { cursor: nextCursor, hasMore, limit: params.limit },
131
+ };
132
+ }
133
+ // Hydrate the channel-aware price + multilingual name override from
134
+ // Postgres. This protects against stale index data and keeps R-18
135
+ // (hide price on non-public channels) authoritative on Postgres.
136
+ //
137
+ // Issue #227 — and the audience answer, for the same reason and in the
138
+ // same place. It is **not** pushed into the Meilisearch filter expression:
139
+ // the index carries `visibility` but has never carried
140
+ // `allowed_organization_ids`, so a filter there could cover one of the two
141
+ // columns and would still need this pass to be correct — while reading, to
142
+ // the next author, as though the question were settled upstream. The cost
143
+ // is the one this path already pays for a stale index: a page can come
144
+ // back shorter than `limit`.
145
+ const products = (await this.products.findByIds(hits.map((h) => h.id))).filter((p) => isProductVisibleTo(p, ctx.audience));
146
+ const productById = new Map(products.map((p) => [p.id, p]));
147
+ // A channel that withholds prices is not asked for them (R-18), so the
148
+ // resolution never runs and every hit reports `null`.
149
+ // The price is the *viewer's*, while the document stays everybody's. A
150
+ // Meilisearch document has never carried a price and does not start now:
151
+ // one index per sales channel, one document per product, and per-buyer
152
+ // pricing would otherwise multiply the corpus by the customer base. This
153
+ // pass already re-reads every hit from Postgres so a stale index cannot
154
+ // decide what a buyer sees; resolving the price for the caller here costs
155
+ // the page four statements (measured, `listing-price-viewer-cost.bench.ts`)
156
+ // and leaves the index shared.
157
+ const priceable = channel.isPublic && products.length > 0;
158
+ const viewerOrganization = priceable
159
+ ? await this.#viewerOrganization(ctx.audience)
160
+ : null;
161
+ const resolvedPrices = priceable
162
+ ? await this.#requireListingPrices().resolveListingPrices({
163
+ products,
164
+ context: {
165
+ salesChannel: { id: channel.id, defaultCurrency: channel.defaultCurrency },
166
+ organization: viewerOrganization,
167
+ },
168
+ })
169
+ : new Map();
170
+ const summaries = [];
171
+ for (const hit of hits) {
172
+ const product = productById.get(hit.id);
173
+ // Index pointed at a deleted row, or at one this audience may not see.
174
+ if (!product)
175
+ continue;
176
+ summaries.push(searchHitSummary(product, hit, channel, ctx.preferredLanguage, resolvedPrices.get(product.id)));
177
+ }
178
+ return {
179
+ data: summaries,
180
+ pagination: { cursor: nextCursor, hasMore, limit: params.limit },
181
+ };
182
+ }
183
+ #requireListingPrices() {
184
+ if (!this.listingPrices) {
185
+ throw new Error('SearchQueryService: the pricing port is not wired — a search hit cannot be priced.');
186
+ }
187
+ return this.listingPrices;
188
+ }
189
+ #requireOrganizations() {
190
+ if (!this.organizations) {
191
+ throw new Error('SearchQueryService: the organization read port is not wired — a signed-in buyer cannot be priced.');
192
+ }
193
+ return this.organizations;
194
+ }
195
+ /**
196
+ * The buying organisation a hit should be priced against — `null` for the
197
+ * anonymous visitor, for a signed-in buyer with no Organization, and for one
198
+ * whose Organization row is gone. The same three-way answer `catalog`'s
199
+ * listing gives, deliberately: the two backends serve the same route.
200
+ */
201
+ async #viewerOrganization(audience) {
202
+ if (audience.organizationId === null)
203
+ return null;
204
+ const record = await this.#requireOrganizations().findById(audience.organizationId);
205
+ if (!record)
206
+ return null;
207
+ return { id: record.id, customerGroupId: record.customerGroupId };
208
+ }
209
+ }
210
+ export class SearchBackendUnavailable extends Error {
211
+ kind;
212
+ code;
213
+ constructor(message,
214
+ /** Which of the two facts this is. */
215
+ kind = 'unreachable',
216
+ /** Meilisearch's own error code for a refusal; `null` when unreachable. */
217
+ code = null) {
218
+ super(message);
219
+ this.kind = kind;
220
+ this.code = code;
221
+ this.name = 'SearchBackendUnavailable';
222
+ }
223
+ }
224
+ /**
225
+ * Conditions this module has already reported. Module scope and never reset —
226
+ * the same shape `plugin.ts` uses for an out-of-scope setting (D-43), and for
227
+ * the same reason: an index setting is a deployment fact, and a public listing
228
+ * would otherwise report it on every request of every page.
229
+ */
230
+ const reportedSearchRefusals = new Set();
231
+ /**
232
+ * Classify a failed Meilisearch call, and report a refusal once per code.
233
+ *
234
+ * The report is `console.error` rather than a request logger because the
235
+ * failure belongs to the module, not to the request that happened to hit it —
236
+ * the same call fails identically for the reindex sweep and the typeahead —
237
+ * and because the consumer's own line (`meilisearch unavailable; falling back
238
+ * to postgres`, at `warn`) is the line this exists to contradict.
239
+ */
240
+ function searchBackendFailure(err, indexUid) {
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ const status = err instanceof MeilisearchApiError ? err.response.status : null;
243
+ // A 4xx is the engine declining the request. 429 is not: it is the engine
244
+ // saying "not now", which is an availability answer.
245
+ const refused = status !== null && status >= 400 && status < 500 && status !== 429;
246
+ if (!refused)
247
+ return new SearchBackendUnavailable(`Meilisearch unavailable: ${message}`);
248
+ const code = (err instanceof MeilisearchApiError ? err.cause?.code : undefined) ?? `http_${status}`;
249
+ if (!reportedSearchRefusals.has(code)) {
250
+ reportedSearchRefusals.add(code);
251
+ console.error(`search: Meilisearch refused a query on "${indexUid}" with "${code}" — this is a ` +
252
+ `configuration defect, not an outage, and every affected listing is silently ` +
253
+ `falling back to Postgres until it is fixed. ${message}`);
254
+ }
255
+ return new SearchBackendUnavailable(`Meilisearch refused the query (${code}): ${message}`, 'refused', code);
256
+ }
257
+ export function buildFilterExpression(params) {
258
+ const out = [];
259
+ if (params.categorySlug) {
260
+ out.push(`categorySlugs = "${escapeFilterValue(params.categorySlug)}"`);
261
+ }
262
+ if (params.attributeFilters) {
263
+ for (const [key, values] of Object.entries(params.attributeFilters)) {
264
+ if (values.length === 0)
265
+ continue;
266
+ const orParts = values.map((v) => `attributes.${key} = "${escapeFilterValue(v)}"`);
267
+ out.push(`(${orParts.join(' OR ')})`);
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+ export function buildSort(sort) {
273
+ // `relevance` and the absent sort both leave the ordering to the engine's
274
+ // ranking rules, which need no sortable attribute.
275
+ if (sort === undefined || sort === 'relevance')
276
+ return [];
277
+ if (sort === '-createdAt')
278
+ return ['createdAt:desc'];
279
+ if (sort === 'name')
280
+ return ['name:asc'];
281
+ if (sort === '-name')
282
+ return ['name:desc'];
283
+ return [];
284
+ }
285
+ function escapeFilterValue(value) {
286
+ return value.replace(/"/g, '\\"');
287
+ }
288
+ function encodeOffsetCursor(offset) {
289
+ return encodeCursor(JSON.stringify({ offset }));
290
+ }
291
+ function decodeOffsetCursor(cursor) {
292
+ if (!cursor)
293
+ return null;
294
+ const raw = decodeCursor(cursor);
295
+ if (!raw)
296
+ return null;
297
+ try {
298
+ const parsed = JSON.parse(raw);
299
+ if (typeof parsed.offset !== 'number' || !Number.isFinite(parsed.offset)) {
300
+ return null;
301
+ }
302
+ return Math.max(0, parsed.offset);
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ /**
309
+ * Project one hydrated hit into a `ProductSummary` (issue #132).
310
+ *
311
+ * `resolvedPrice` is the chain's answer for this product, or `undefined` when
312
+ * the channel withheld the resolution — both render `null`, which is the one
313
+ * spelling `ProductSummary.price` has for "no price". Exported so the
314
+ * projection can be asserted without a Meilisearch round trip.
315
+ */
316
+ export function searchHitSummary(product, hit, channel, preferredLanguage, resolvedPrice) {
317
+ const price = resolvedPrice === undefined ? null : listingPriceMoney(resolvedPrice);
318
+ return {
319
+ id: product.id,
320
+ sku: product.sku,
321
+ type: product.type,
322
+ name: pickLang(product.name, preferredLanguage, channel) || hit.name,
323
+ slug: product.slug,
324
+ categorySlugs: hit.categorySlugs,
325
+ primaryAssetUrl: hit.primaryAssetUrl ?? null,
326
+ price,
327
+ stockIndicator: null,
328
+ stockLevel: null,
329
+ };
330
+ }
331
+ function pickLang(blob, preferred, channel) {
332
+ const candidates = [
333
+ preferred,
334
+ channel?.defaultLanguage,
335
+ 'en-US',
336
+ 'en',
337
+ ].filter((v) => typeof v === 'string');
338
+ for (const lang of candidates) {
339
+ const hit = blob[lang];
340
+ if (hit)
341
+ return hit;
342
+ }
343
+ const anyKey = Object.keys(blob)[0];
344
+ return anyKey ? (blob[anyKey] ?? '') : '';
345
+ }
346
+ //# sourceMappingURL=search-query.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-query.service.js","sourceRoot":"","sources":["../../../src/backend/services/search-query.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { SearchIndexer } from './search-indexer.js';
3
+ /**
4
+ * SearchReindexWorker — periodic full Meilisearch reindex.
5
+ *
6
+ * Wraps {@link SearchIndexer.reindexAllChannels} so the catalogue stays in
7
+ * sync even when an incremental event was missed (e.g. a bulk import that
8
+ * bypassed the catalog event bus, or a Meilisearch restart). The sweep runs
9
+ * every `search.reindex_interval_minutes` minutes (Settings, default 10); the
10
+ * same `reindex()` entry point also backs the admin "Reindex now" button.
11
+ *
12
+ * Public `reindex()` is a plain async function so tests can drive it directly
13
+ * without Redis/BullMQ and so the admin route can call it synchronously.
14
+ */
15
+ export interface SearchReindexWorkerDeps {
16
+ emFactory: () => EntityManager;
17
+ indexer: SearchIndexer;
18
+ }
19
+ export declare class SearchReindexWorker {
20
+ private readonly deps;
21
+ constructor(deps: SearchReindexWorkerDeps);
22
+ reindex(): Promise<{
23
+ channelsReindexed: number;
24
+ documentCount: number;
25
+ }>;
26
+ }
27
+ //# sourceMappingURL=search-reindex-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-reindex-worker.d.ts","sourceRoot":"","sources":["../../../src/backend/services/search-reindex-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,aAAa,CAAC;IAC/B,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,qBAAa,mBAAmB;IAClB,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,uBAAuB;IAEpD,OAAO,IAAI,OAAO,CAAC;QAAE,iBAAiB,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CAM/E"}
@@ -0,0 +1,13 @@
1
+ export class SearchReindexWorker {
2
+ deps;
3
+ constructor(deps) {
4
+ this.deps = deps;
5
+ }
6
+ async reindex() {
7
+ const em = this.deps.emFactory();
8
+ const results = await this.deps.indexer.reindexAllChannels(em);
9
+ const documentCount = results.reduce((sum, r) => sum + r.documentCount, 0);
10
+ return { channelsReindexed: results.length, documentCount };
11
+ }
12
+ }
13
+ //# sourceMappingURL=search-reindex-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-reindex-worker.js","sourceRoot":"","sources":["../../../src/backend/services/search-reindex-worker.ts"],"names":[],"mappings":"AAoBA,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,IAA6B;QAA7B,SAAI,GAAJ,IAAI,CAAyB;IAAG,CAAC;IAE9D,KAAK,CAAC,OAAO;QACX,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QACjC,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC;QAC/D,MAAM,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC;QAC3E,OAAO,EAAE,iBAAiB,EAAE,OAAO,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC;IAC9D,CAAC;CACF"}
@@ -0,0 +1,53 @@
1
+ import type { ProductSummary } from '@endora-commerce/contracts';
2
+ import { SearchBackendUnavailable, type SearchQueryService, type SearchQueryContext } from './search-query.service.js';
3
+ /**
4
+ * SearchSuggestService — feature 006 / US1 / T012.
5
+ *
6
+ * Narrow typeahead-popup adapter on top of {@link SearchQueryService}.
7
+ * The full results page reuses `/api/v1/catalog/products?q=…`; this service
8
+ * exists so the popup can:
9
+ *
10
+ * 1. Cap the response to a small slice (no FilterPanel, no facet
11
+ * metadata, no pagination) — payload bounded by `limit`.
12
+ * 2. Enforce the same minimum-query-length contract the storefront
13
+ * enforces client-side, server-side too (the popup can never bypass
14
+ * it via curl).
15
+ * 3. Echo the resolved threshold back to the client so the storefront
16
+ * component can self-correct without a separate settings round-trip.
17
+ *
18
+ * Per-channel defaults for `limit` and `minimumQueryLength` will move to
19
+ * the Settings module in US2 (T024 wires the settings-aware provider);
20
+ * for US1 they fall back to the foundation-time defaults declared in the
21
+ * data model.
22
+ */
23
+ export declare const DEFAULT_SUGGESTION_COUNT = 8;
24
+ export declare const DEFAULT_MINIMUM_QUERY_LENGTH = 3;
25
+ export interface SuggestionCountConfig {
26
+ defaultLimit: number;
27
+ minimumQueryLength: number;
28
+ }
29
+ export interface SearchSuggestParams {
30
+ q: string;
31
+ limit?: number | undefined;
32
+ }
33
+ export interface SearchSuggestResult {
34
+ data: ProductSummary[];
35
+ meta: {
36
+ limit: number;
37
+ minimumQueryLength: number;
38
+ queryEcho: string;
39
+ };
40
+ }
41
+ /** Sentinel thrown when the trimmed query is shorter than the resolved minimum. */
42
+ export declare class QueryTooShort extends Error {
43
+ readonly minimumQueryLength: number;
44
+ constructor(minimumQueryLength: number);
45
+ }
46
+ export declare class SearchSuggestService {
47
+ private readonly searchQueryService;
48
+ private readonly resolveConfig;
49
+ constructor(searchQueryService: SearchQueryService, resolveConfig?: (ctx: SearchQueryContext) => Promise<SuggestionCountConfig> | SuggestionCountConfig);
50
+ suggest(params: SearchSuggestParams, ctx: SearchQueryContext): Promise<SearchSuggestResult>;
51
+ }
52
+ export { SearchBackendUnavailable };
53
+ //# sourceMappingURL=search-suggest.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-suggest.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/search-suggest.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EACL,wBAAwB,EACxB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACxB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,eAAO,MAAM,4BAA4B,IAAI,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,cAAc,EAAE,CAAC;IACvB,IAAI,EAAE;QACJ,KAAK,EAAE,MAAM,CAAC;QACd,kBAAkB,EAAE,MAAM,CAAC;QAC3B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,mFAAmF;AACnF,qBAAa,aAAc,SAAQ,KAAK;aACV,kBAAkB,EAAE,MAAM;gBAA1B,kBAAkB,EAAE,MAAM;CAIvD;AAED,qBAAa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa;gBADb,kBAAkB,EAAE,kBAAkB,EACtC,aAAa,GAAE,CAC9B,GAAG,EAAE,kBAAkB,KACpB,OAAO,CAAC,qBAAqB,CAAC,GAAG,qBAGpC;IAGE,OAAO,CACX,MAAM,EAAE,mBAAmB,EAC3B,GAAG,EAAE,kBAAkB,GACtB,OAAO,CAAC,mBAAmB,CAAC;CAsBhC;AAED,OAAO,EAAE,wBAAwB,EAAE,CAAC"}
@@ -0,0 +1,64 @@
1
+ import { SearchBackendUnavailable, } from './search-query.service.js';
2
+ /**
3
+ * SearchSuggestService — feature 006 / US1 / T012.
4
+ *
5
+ * Narrow typeahead-popup adapter on top of {@link SearchQueryService}.
6
+ * The full results page reuses `/api/v1/catalog/products?q=…`; this service
7
+ * exists so the popup can:
8
+ *
9
+ * 1. Cap the response to a small slice (no FilterPanel, no facet
10
+ * metadata, no pagination) — payload bounded by `limit`.
11
+ * 2. Enforce the same minimum-query-length contract the storefront
12
+ * enforces client-side, server-side too (the popup can never bypass
13
+ * it via curl).
14
+ * 3. Echo the resolved threshold back to the client so the storefront
15
+ * component can self-correct without a separate settings round-trip.
16
+ *
17
+ * Per-channel defaults for `limit` and `minimumQueryLength` will move to
18
+ * the Settings module in US2 (T024 wires the settings-aware provider);
19
+ * for US1 they fall back to the foundation-time defaults declared in the
20
+ * data model.
21
+ */
22
+ export const DEFAULT_SUGGESTION_COUNT = 8;
23
+ export const DEFAULT_MINIMUM_QUERY_LENGTH = 3;
24
+ /** Sentinel thrown when the trimmed query is shorter than the resolved minimum. */
25
+ export class QueryTooShort extends Error {
26
+ minimumQueryLength;
27
+ constructor(minimumQueryLength) {
28
+ super(`query is shorter than the minimum length of ${minimumQueryLength}`);
29
+ this.minimumQueryLength = minimumQueryLength;
30
+ this.name = 'QueryTooShort';
31
+ }
32
+ }
33
+ export class SearchSuggestService {
34
+ searchQueryService;
35
+ resolveConfig;
36
+ constructor(searchQueryService, resolveConfig = () => ({
37
+ defaultLimit: DEFAULT_SUGGESTION_COUNT,
38
+ minimumQueryLength: DEFAULT_MINIMUM_QUERY_LENGTH,
39
+ })) {
40
+ this.searchQueryService = searchQueryService;
41
+ this.resolveConfig = resolveConfig;
42
+ }
43
+ async suggest(params, ctx) {
44
+ const cfg = await this.resolveConfig(ctx);
45
+ const trimmed = params.q.trim();
46
+ if (trimmed.length < cfg.minimumQueryLength) {
47
+ throw new QueryTooShort(cfg.minimumQueryLength);
48
+ }
49
+ const limit = params.limit ?? cfg.defaultLimit;
50
+ // SearchQueryService throws SearchBackendUnavailable on Meilisearch
51
+ // outage; the route wrapper translates it into a 503.
52
+ const result = await this.searchQueryService.listProducts({ q: trimmed, limit, sort: 'relevance' }, ctx);
53
+ return {
54
+ data: result.data,
55
+ meta: {
56
+ limit,
57
+ minimumQueryLength: cfg.minimumQueryLength,
58
+ queryEcho: trimmed,
59
+ },
60
+ };
61
+ }
62
+ }
63
+ export { SearchBackendUnavailable };
64
+ //# sourceMappingURL=search-suggest.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-suggest.service.js","sourceRoot":"","sources":["../../../src/backend/services/search-suggest.service.ts"],"names":[],"mappings":"AACA,OAAO,EACL,wBAAwB,GAGzB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC;AAC1C,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAqB9C,mFAAmF;AACnF,MAAM,OAAO,aAAc,SAAQ,KAAK;IACV;IAA5B,YAA4B,kBAA0B;QACpD,KAAK,CAAC,+CAA+C,kBAAkB,EAAE,CAAC,CAAC;QADjD,uBAAkB,GAAlB,kBAAkB,CAAQ;QAEpD,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED,MAAM,OAAO,oBAAoB;IAEZ;IACA;IAFnB,YACmB,kBAAsC,EACtC,gBAE6C,GAAG,EAAE,CAAC,CAAC;QACnE,YAAY,EAAE,wBAAwB;QACtC,kBAAkB,EAAE,4BAA4B;KACjD,CAAC;QANe,uBAAkB,GAAlB,kBAAkB,CAAoB;QACtC,kBAAa,GAAb,aAAa,CAK5B;IACD,CAAC;IAEJ,KAAK,CAAC,OAAO,CACX,MAA2B,EAC3B,GAAuB;QAEvB,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAChC,IAAI,OAAO,CAAC,MAAM,GAAG,GAAG,CAAC,kBAAkB,EAAE,CAAC;YAC5C,MAAM,IAAI,aAAa,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;QAClD,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,GAAG,CAAC,YAAY,CAAC;QAC/C,oEAAoE;QACpE,sDAAsD;QACtD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,YAAY,CACvD,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,EACxC,GAAG,CACJ,CAAC;QACF,OAAO;YACL,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,IAAI,EAAE;gBACJ,KAAK;gBACL,kBAAkB,EAAE,GAAG,CAAC,kBAAkB;gBAC1C,SAAS,EAAE,OAAO;aACnB;SACF,CAAC;IACJ,CAAC;CACF;AAED,OAAO,EAAE,wBAAwB,EAAE,CAAC"}
@@ -0,0 +1,62 @@
1
+ import type { CatalogProductReadPort, CatalogProductRecord, DisplayMode, OrganizationDetailsPort, OrganizationRecord } from '@endora-commerce/contracts';
2
+ import type { SuggestionPricingEnricher } from '../routes.public.js';
3
+ /**
4
+ * Builds the {@link SuggestionPricingEnricher} the suggest route uses to
5
+ * attach the per-customer price-list resolution to each typeahead hit.
6
+ *
7
+ * The search module stays isolated from the price-lists module's concrete
8
+ * service: it depends only on the narrow {@link SuggestionPriceResolverPort}
9
+ * structural port below, which the container's `pricingService` satisfies with
10
+ * the price-lists `PricingService`. (Constitution I — modules talk through
11
+ * ports.) Its two row parameters are the published contract records, so the
12
+ * declaration names no file in `catalog`'s or `organizations`' directory.
13
+ *
14
+ * Pricing is resolved per hit in parallel, capped by the suggest `limit`
15
+ * (default 8), and the resolver caches per-tuple for 60s, so the popup adds
16
+ * at most one bounded fan-out. A resolver *refusal* degrades that hit to the
17
+ * plain summary — the popup never fails over a pricing hiccup — but the
18
+ * module-presence answer is not a hiccup and is re-thrown (issue #84): a
19
+ * suggest popup with every price missing reads to a buyer as a catalogue
20
+ * without prices, not as `price_lists` being switched off.
21
+ */
22
+ export interface SuggestionPriceResolverPort {
23
+ resolveEngine(input: {
24
+ product: CatalogProductRecord;
25
+ variantId?: string | null;
26
+ context: {
27
+ quantity: number;
28
+ organization?: OrganizationRecord | null;
29
+ salesChannel: {
30
+ id: string;
31
+ defaultCurrency: string;
32
+ };
33
+ currencyCode?: string;
34
+ };
35
+ }): Promise<{
36
+ base: {
37
+ bracket: {
38
+ amount: string;
39
+ } | null;
40
+ };
41
+ sale: {
42
+ bracket: {
43
+ amount: string;
44
+ };
45
+ } | null;
46
+ displayMode: DisplayMode;
47
+ currencyCode: string;
48
+ }>;
49
+ }
50
+ export declare function createSuggestionPricingEnricher(deps: {
51
+ /**
52
+ * Feature 075, Phase C — the two rows a priced suggestion needs, each read
53
+ * from the module that owns it instead of out of its table. `em.find(Product,
54
+ * …)` and `em.findOne(Organization, …)` from inside this module went on
55
+ * answering after an operator had switched either owner off, because
56
+ * deactivation drops no tables; the ports fail closed instead.
57
+ */
58
+ catalogProducts: CatalogProductReadPort;
59
+ organizations: OrganizationDetailsPort;
60
+ pricingService: SuggestionPriceResolverPort;
61
+ }): SuggestionPricingEnricher;
62
+ //# sourceMappingURL=suggestion-pricing-enricher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggestion-pricing-enricher.d.ts","sourceRoot":"","sources":["../../../src/backend/services/suggestion-pricing-enricher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,oBAAoB,EACpB,WAAW,EACX,uBAAuB,EACvB,kBAAkB,EAGnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAErE;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,2BAA2B;IAC1C,aAAa,CAAC,KAAK,EAAE;QACnB,OAAO,EAAE,oBAAoB,CAAC;QAC9B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC1B,OAAO,EAAE;YACP,QAAQ,EAAE,MAAM,CAAC;YACjB,YAAY,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;YACzC,YAAY,EAAE;gBAAE,EAAE,EAAE,MAAM,CAAC;gBAAC,eAAe,EAAE,MAAM,CAAA;aAAE,CAAC;YACtD,YAAY,CAAC,EAAE,MAAM,CAAC;SACvB,CAAC;KACH,GAAG,OAAO,CAAC;QACV,IAAI,EAAE;YAAE,OAAO,EAAE;gBAAE,MAAM,EAAE,MAAM,CAAA;aAAE,GAAG,IAAI,CAAA;SAAE,CAAC;QAC7C,IAAI,EAAE;YAAE,OAAO,EAAE;gBAAE,MAAM,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,GAAG,IAAI,CAAC;QAC7C,WAAW,EAAE,WAAW,CAAC;QACzB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC,CAAC;CACJ;AAED,wBAAgB,+BAA+B,CAAC,IAAI,EAAE;IACpD;;;;;;OAMG;IACH,eAAe,EAAE,sBAAsB,CAAC;IACxC,aAAa,EAAE,uBAAuB,CAAC;IACvC,cAAc,EAAE,2BAA2B,CAAC;CAC7C,GAAG,yBAAyB,CA0C5B"}
@@ -0,0 +1,41 @@
1
+ import { rethrowIfModuleDisabled } from '@endora-commerce/platform/kernel';
2
+ export function createSuggestionPricingEnricher(deps) {
3
+ const { catalogProducts, organizations, pricingService } = deps;
4
+ return async (items, ctx) => {
5
+ if (items.length === 0)
6
+ return [];
7
+ // Feature 053 / FR-002: the channel is resolved once upstream and handed in.
8
+ const channel = ctx.resolvedChannel;
9
+ const organization = ctx.organizationId
10
+ ? await organizations.findById(ctx.organizationId)
11
+ : null;
12
+ const products = await catalogProducts.findByIds(items.map((i) => i.id));
13
+ const byId = new Map(products.map((p) => [p.id, p]));
14
+ return Promise.all(items.map(async (item) => {
15
+ const product = byId.get(item.id);
16
+ if (!product)
17
+ return { ...item };
18
+ try {
19
+ const out = await pricingService.resolveEngine({
20
+ product,
21
+ context: { quantity: 1, organization, salesChannel: channel },
22
+ });
23
+ return {
24
+ ...item,
25
+ basePrice: out.base.bracket
26
+ ? { amount: out.base.bracket.amount, currency: out.currencyCode }
27
+ : null,
28
+ salePrice: out.sale
29
+ ? { amount: out.sale.bracket.amount, currency: out.currencyCode }
30
+ : null,
31
+ priceDisplayMode: out.displayMode,
32
+ };
33
+ }
34
+ catch (error) {
35
+ rethrowIfModuleDisabled(error);
36
+ return { ...item };
37
+ }
38
+ }));
39
+ };
40
+ }
41
+ //# sourceMappingURL=suggestion-pricing-enricher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggestion-pricing-enricher.js","sourceRoot":"","sources":["../../../src/backend/services/suggestion-pricing-enricher.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kCAAkC,CAAC;AAyC3E,MAAM,UAAU,+BAA+B,CAAC,IAW/C;IACC,MAAM,EAAE,eAAe,EAAE,aAAa,EAAE,cAAc,EAAE,GAAG,IAAI,CAAC;IAEhE,OAAO,KAAK,EAAE,KAAuB,EAAE,GAAG,EAAgC,EAAE;QAC1E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAElC,6EAA6E;QAC7E,MAAM,OAAO,GAAG,GAAG,CAAC,eAAe,CAAC;QAEpC,MAAM,YAAY,GAAG,GAAG,CAAC,cAAc;YACrC,CAAC,CAAC,MAAM,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC,cAAc,CAAC;YAClD,CAAC,CAAC,IAAI,CAAC;QAET,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACzE,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAErD,OAAO,OAAO,CAAC,GAAG,CAChB,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAA8B,EAAE;YACnD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAClC,IAAI,CAAC,OAAO;gBAAE,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC;YACjC,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,cAAc,CAAC,aAAa,CAAC;oBAC7C,OAAO;oBACP,OAAO,EAAE,EAAE,QAAQ,EAAE,CAAC,EAAE,YAAY,EAAE,YAAY,EAAE,OAAO,EAAE;iBAC9D,CAAC,CAAC;gBACH,OAAO;oBACL,GAAG,IAAI;oBACP,SAAS,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO;wBACzB,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,GAAG,CAAC,YAAY,EAAE;wBACjE,CAAC,CAAC,IAAI;oBACR,SAAS,EAAE,GAAG,CAAC,IAAI;wBACjB,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,GAAG,CAAC,YAAY,EAAE;wBACjE,CAAC,CAAC,IAAI;oBACR,gBAAgB,EAAE,GAAG,CAAC,WAAW;iBAClC,CAAC;YACJ,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,uBAAuB,CAAC,KAAK,CAAC,CAAC;gBAC/B,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC;YACrB,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}