@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,670 @@
1
+ import { Meilisearch } from 'meilisearch';
2
+ import { resolveAttribute, SYSTEM_ATTRIBUTE_SCOPES, } from '@endora-commerce/contracts';
3
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
4
+ import { DEFAULT_INDEX_TASK_TIMEOUT_SECONDS, SEARCH_SETTING_CODES } from '../../manifest.js';
5
+ /**
6
+ * SearchIndexer (T067 — initial offline path).
7
+ *
8
+ * Pushes the public product surface into a Meilisearch index per Sales
9
+ * Channel. Today the storefront catalog list still runs through Postgres
10
+ * (`catalog-query.service.ts`); the index is consumed once T068
11
+ * (`search-query.service.ts`) ships. Until then this indexer is the
12
+ * groundwork — populating it on every catalog mutation keeps the index
13
+ * fresh so the cut-over is a single-line change in the read path.
14
+ *
15
+ * The indexer is intentionally idempotent: each call upserts every
16
+ * product into the channel index and reapplies the searchable / filterable /
17
+ * sortable settings — the first two derived from the live `product_attributes`
18
+ * rows, the third fixed by {@link SORTABLE_ATTRIBUTES}. Re-running it after a
19
+ * shape change is the right way to reindex, and the only way to add a document
20
+ * field that was not written before.
21
+ */
22
+ const FALLBACK_LOCALE = 'en-US';
23
+ /**
24
+ * How many member ids one `listEntityIdsForChannel` page carries. The accessor
25
+ * is paginated and defaults to 100; a full channel reindex wants the whole
26
+ * assortment, so the pages are walked to `total` — the shape `seo`'s sitemap
27
+ * generator settled on for the same read.
28
+ */
29
+ const MEMBERSHIP_PAGE_SIZE = 500;
30
+ /**
31
+ * The document fields a channel index may be sorted on — issue #287.
32
+ *
33
+ * Meilisearch refuses a sort on any field outside `sortableAttributes`, and
34
+ * this indexer never called `updateSortableAttributes`, so the setting was `[]`
35
+ * on all five live indexes and `name:asc` came back as `invalid_search_sort`.
36
+ * Three of the four sorts the listing contract offers were therefore answered
37
+ * by the unreachable-index fallback — correct results, re-read from the very
38
+ * Postgres query the index exists to spare, under a log line that called a
39
+ * healthy engine unavailable.
40
+ *
41
+ * The declaration is single on purpose: this constant is what the indexer
42
+ * applies to every index **and** the alphabet `buildSort` may emit (its return
43
+ * type is built from it). A sort token cannot be added on the query side
44
+ * without appearing here, which is the only structural guarantee that stops
45
+ * the two halves drifting apart again.
46
+ *
47
+ * Every entry costs index size and settings-update time, so this is the sorts
48
+ * the contract actually offers, not a list of fields that might one day be
49
+ * sorted on. `updatedAt` is deliberately absent: no sort token names it, it is
50
+ * the freshness marker rather than an ordering, and it is what `-createdAt`
51
+ * used to be mistranslated into.
52
+ */
53
+ export const SORTABLE_ATTRIBUTES = ['createdAt', 'name'];
54
+ /**
55
+ * The default wait, in milliseconds. Derived from the manifest so the constant
56
+ * and the Setting's default cannot drift into two answers.
57
+ */
58
+ export const DEFAULT_INDEX_TASK_TIMEOUT_MS = DEFAULT_INDEX_TASK_TIMEOUT_SECONDS * 1000;
59
+ /**
60
+ * A Meilisearch task came back in a state that is not `succeeded`.
61
+ *
62
+ * This error exists because `waitForTask` **does not throw for a task that
63
+ * failed** — it resolves, carrying `status: 'failed'` and Meilisearch's own
64
+ * `error` object. Every one of this file's twelve waits discarded that value,
65
+ * so a refused document batch, a rejected settings update and an applied one
66
+ * were the same thing to the caller: `reindexChannel` went on to report a
67
+ * document count for an index Meilisearch had written nothing into.
68
+ */
69
+ export class SearchIndexTaskFailed extends Error {
70
+ taskUid;
71
+ what;
72
+ status;
73
+ /** Meilisearch's own error code, e.g. `missing_document_id`. */
74
+ meilisearchCode;
75
+ constructor(args) {
76
+ super(`Meilisearch ${args.what} (task ${args.taskUid}) came back "${args.status}"` +
77
+ (args.meilisearchCode ? ` [${args.meilisearchCode}]` : '') +
78
+ (args.detail ? `: ${args.detail}` : ''));
79
+ this.name = 'SearchIndexTaskFailed';
80
+ this.taskUid = args.taskUid;
81
+ this.what = args.what;
82
+ this.status = args.status;
83
+ this.meilisearchCode = args.meilisearchCode;
84
+ }
85
+ }
86
+ /**
87
+ * The wait expired. The task is **still running** — Meilisearch was never told
88
+ * to stop, because giving up on a wait cancels nothing.
89
+ *
90
+ * It is a separate error from {@link SearchIndexTaskFailed} on purpose, and the
91
+ * separation is the point of the repair rather than a nicety: the two arrive at
92
+ * an operator with opposite remedies. This one says "the queue outlasted the
93
+ * wait — raise `search.index_task_timeout_seconds`, or let the instance drain";
94
+ * the other says "the write was refused, and here is what Meilisearch said".
95
+ * Under the client's 5000 ms default these were one symptom, and it was the
96
+ * benign one that was raised while the malignant one was silent.
97
+ */
98
+ export class SearchIndexTaskStillRunning extends Error {
99
+ taskUid;
100
+ what;
101
+ timeoutMs;
102
+ constructor(args) {
103
+ super(`Meilisearch ${args.what} (task ${args.taskUid}) is still running after ` +
104
+ `${args.timeoutMs} ms. The task was not cancelled and may yet succeed. If this ` +
105
+ `instance is busy or the catalogue is large, raise ` +
106
+ `"${SEARCH_SETTING_CODES.INDEX_TASK_TIMEOUT_SECONDS}".`);
107
+ this.name = 'SearchIndexTaskStillRunning';
108
+ this.taskUid = args.taskUid;
109
+ this.what = args.what;
110
+ this.timeoutMs = args.timeoutMs;
111
+ }
112
+ }
113
+ /**
114
+ * Wait for one Meilisearch task and give its three outcomes three names.
115
+ *
116
+ * This is the **only** place this module awaits a task, which is what makes the
117
+ * timeout one number rather than eleven literals, and what makes the
118
+ * failed-status check impossible to forget at the thirteenth call site.
119
+ *
120
+ * `tolerate` names Meilisearch error codes that are an expected, benign outcome
121
+ * for **this** wait, and it is deliberately a per-call argument rather than a
122
+ * blanket leniency: the one caller that needs it is `ensureIndex`, whose
123
+ * `index_already_exists` is another process having created the index first —
124
+ * which satisfies the postcondition rather than violating it.
125
+ */
126
+ export async function awaitIndexTask(tasks, task, what, timeoutMs, tolerate = []) {
127
+ let settled;
128
+ try {
129
+ settled = await tasks.waitForTask(task.taskUid, { timeout: timeoutMs });
130
+ }
131
+ catch (error) {
132
+ // Matched on `name` rather than on the class: this module holds no value
133
+ // import of the client's error types, and the name is what the client sets.
134
+ if (error instanceof Error && error.name === 'MeilisearchTaskTimeOutError') {
135
+ throw new SearchIndexTaskStillRunning({
136
+ taskUid: task.taskUid,
137
+ what,
138
+ timeoutMs,
139
+ });
140
+ }
141
+ // Anything else — an unreachable host, an auth refusal — is not this
142
+ // function's to interpret and is not swallowed.
143
+ throw error;
144
+ }
145
+ if (settled.status === 'succeeded')
146
+ return;
147
+ const code = settled.error?.code;
148
+ if (code !== undefined && tolerate.includes(code))
149
+ return;
150
+ throw new SearchIndexTaskFailed({
151
+ taskUid: task.taskUid,
152
+ what,
153
+ status: settled.status,
154
+ meilisearchCode: code,
155
+ detail: settled.error?.message,
156
+ });
157
+ }
158
+ export class SearchIndexer {
159
+ client;
160
+ locale;
161
+ attributeRead;
162
+ products;
163
+ categories;
164
+ channelMembership;
165
+ resolveTaskTimeoutMs;
166
+ constructor(options) {
167
+ const host = options.meilisearchHost ??
168
+ process.env['MEILISEARCH_URL'] ??
169
+ 'http://localhost:7700';
170
+ const apiKey = options.meilisearchApiKey ??
171
+ process.env['MEILISEARCH_API_KEY'] ??
172
+ undefined;
173
+ this.client = new Meilisearch(apiKey ? { host, apiKey } : { host });
174
+ this.locale = options.locale ?? FALLBACK_LOCALE;
175
+ this.attributeRead = options.attributeRead;
176
+ this.products = options.products;
177
+ this.categories = options.categories;
178
+ this.channelMembership = options.channelMembership;
179
+ this.resolveTaskTimeoutMs =
180
+ options.resolveTaskTimeoutMs ?? (async () => DEFAULT_INDEX_TASK_TIMEOUT_MS);
181
+ }
182
+ /**
183
+ * Every Meilisearch task this module waits for goes through here — see
184
+ * {@link awaitIndexTask} for why the wait has one home and three outcomes.
185
+ */
186
+ async awaitTask(task, what, tolerate = []) {
187
+ await awaitIndexTask(this.client.tasks, task, what, await this.resolveTaskTimeoutMs(), tolerate);
188
+ }
189
+ /**
190
+ * Drop + recreate the channel index, reapply the searchable / filterable /
191
+ * sortable settings, then push every product visible in that channel.
192
+ * Returns counts so callers can log a summary.
193
+ */
194
+ async reindexChannel(em, channel) {
195
+ const indexUid = indexUidFor(channel);
196
+ const index = await this.ensureIndex(indexUid);
197
+ // The membership accessor answers which products the channel carries; the
198
+ // publishable narrowing that used to ride along in the same join is now
199
+ // `isPublishable`, the one predicate the incremental upsert already used.
200
+ // Two owners, two reads, one definition of publishable.
201
+ const memberIds = await this.channelMemberProductIds(channel.id);
202
+ const products = (await this.products.findByIds(memberIds)).filter(isPublishable);
203
+ const productIds = products.map((p) => p.id);
204
+ // Feature 068 — an inactive category must not survive in the indexed
205
+ // `categorySlugs`, or it keeps working as a storefront PLP filter.
206
+ const categoryRows = await this.categories.listAssignmentsForProducts(productIds, {
207
+ activeOnly: true,
208
+ });
209
+ const categoriesByProduct = new Map();
210
+ for (const row of categoryRows) {
211
+ const list = categoriesByProduct.get(row.productId) ?? [];
212
+ list.push({ id: row.categoryId, slug: row.slug });
213
+ categoriesByProduct.set(row.productId, list);
214
+ }
215
+ // Feature 012 / FR-035 — pre-load every searchable select-style
216
+ // attribute and its option labels so buildDocument() can render the
217
+ // per-locale text for each product's selected value(s).
218
+ const optionLookup = await this.loadSearchableOptionLookup(em, this.locale);
219
+ // Feature 022 — fetch every override row for this batch of products
220
+ // in one query, then bucket by productId. Each document is built
221
+ // with the channel's defaultLanguage so per-(channel, language)
222
+ // overrides for system Name / Description flow through.
223
+ const overrideRows = await this.products.listValueOverridesByProductIds(productIds);
224
+ const overridesByProduct = new Map();
225
+ for (const row of overrideRows) {
226
+ const list = overridesByProduct.get(row.productId) ?? [];
227
+ list.push(row);
228
+ overridesByProduct.set(row.productId, list);
229
+ }
230
+ const documents = products.map((p) => buildDocument(p, categoriesByProduct.get(p.id) ?? [], this.locale, optionLookup, {
231
+ overrides: overridesByProduct.get(p.id) ?? [],
232
+ channelId: channel.id,
233
+ languageCode: channel.defaultLanguage,
234
+ }));
235
+ // Wipe the index first so removed-from-channel products disappear from
236
+ // search. The offline reindex contract is "the index after this call
237
+ // exactly mirrors Postgres for this channel". Event-driven incremental
238
+ // reindex is a separate code path that can upsert without wiping.
239
+ const wipeTask = await index.deleteAllDocuments();
240
+ // O(the corpus being dropped). A wait that expires here is the worst of the
241
+ // twelve: the documents are already gone and the push below never runs, so
242
+ // an index that was merely slow is left empty.
243
+ await this.awaitTask(wipeTask, `wipe of ${indexUid}`);
244
+ // The settings go in while the index is empty, and that ordering is the
245
+ // whole reason they moved here from after the document push (issue #287).
246
+ // A settings update re-indexes the corpus it lands on, so on an empty
247
+ // index it is instant and can be awaited without risking the task-wait
248
+ // timeout a large channel would blow through — and awaiting it is what
249
+ // makes this method's postcondition true on return. Applied afterwards,
250
+ // every sorted query in the window between the document task settling and
251
+ // the settings task settling was answered `invalid_search_sort` and
252
+ // degraded to Postgres.
253
+ const { searchable, filterable } = await this.attributeSettings();
254
+ const settingsTasks = await Promise.all([
255
+ index.updateSearchableAttributes(searchable),
256
+ index.updateFilterableAttributes(filterable),
257
+ index.updateSortableAttributes([...SORTABLE_ATTRIBUTES]),
258
+ ]);
259
+ for (const task of settingsTasks) {
260
+ // O(1) by construction — this is the ordering the comment above is about.
261
+ // The wait is still not free: it queues behind whatever else the instance
262
+ // is doing, which is the term no site here controls.
263
+ await this.awaitTask(task, `index settings for ${indexUid}`);
264
+ }
265
+ if (documents.length > 0) {
266
+ const task = await index.addDocuments(documents, { primaryKey: 'id' });
267
+ // Wait for the task to settle so the documents are queryable when
268
+ // this method returns. Production callers (event-driven reindex)
269
+ // could fire-and-forget; the offline reindex CLI + tests both want
270
+ // the synchronous guarantee.
271
+ await this.awaitTask(task, `${documents.length}-document push to ${indexUid}`);
272
+ }
273
+ return {
274
+ indexUid,
275
+ documentCount: documents.length,
276
+ searchableAttributes: searchable,
277
+ filterableAttributes: filterable,
278
+ sortableAttributes: [...SORTABLE_ATTRIBUTES],
279
+ };
280
+ }
281
+ /**
282
+ * Incremental upsert of a single product into every channel index that
283
+ * publishes it. Used by the event-driven subscriber. Performs no index
284
+ * wipe and no setting changes — just a per-channel `addDocuments` (Meili
285
+ * upsert semantics) for the channels the product currently belongs to,
286
+ * plus a `deleteDocument` from any channel index it was removed from.
287
+ *
288
+ * Returns the channel codes that were touched so callers can log a
289
+ * one-line summary.
290
+ */
291
+ async upsertProduct(em, productId) {
292
+ const product = await this.products.findById(productId);
293
+ if (!product)
294
+ return [];
295
+ const channels = await em.find(SalesChannel, {});
296
+ const linkedChannelIds = new Set((await this.channelMembership.listChannelsForEntity('product', productId)).map((c) => c.id));
297
+ // Feature 068 — same activation filter as the full reindex.
298
+ const categoryRows = await this.categories.listAssignmentsForProducts([productId], {
299
+ activeOnly: true,
300
+ });
301
+ const categories = categoryRows.map((r) => ({ id: r.categoryId, slug: r.slug }));
302
+ // Feature 012 / FR-035 — same per-locale option-label projection
303
+ // used by the offline reindex. Per-product upsert is incremental,
304
+ // so we only build the lookup once per call.
305
+ const optionLookup = await this.loadSearchableOptionLookup(em, this.locale);
306
+ // Feature 022 — per-channel overrides for system Name / Description.
307
+ // Fetch once; the resolver then runs per (channel, channel.defaultLanguage).
308
+ const overrides = await this.products.listValueOverridesByProductIds([productId]);
309
+ const publishable = isPublishable(product);
310
+ const touched = [];
311
+ for (const channel of channels) {
312
+ const indexUid = indexUidFor(channel);
313
+ const index = await this.ensureIndex(indexUid);
314
+ if (linkedChannelIds.has(channel.id) && publishable) {
315
+ const document = buildDocument(product, categories, this.locale, optionLookup, {
316
+ overrides,
317
+ channelId: channel.id,
318
+ languageCode: channel.defaultLanguage,
319
+ });
320
+ const task = await index.addDocuments([document], { primaryKey: 'id' });
321
+ await this.awaitTask(task, `upsert of product ${productId} into ${indexUid}`);
322
+ }
323
+ else {
324
+ // Either unlinked or no longer publishable — make sure the doc is gone.
325
+ const task = await index.deleteDocument(productId);
326
+ await this.awaitTask(task, `removal of product ${productId} from ${indexUid}`);
327
+ }
328
+ touched.push(channel.code);
329
+ }
330
+ return touched;
331
+ }
332
+ /**
333
+ * Feature 068 — re-index every product assigned to a category or any of its
334
+ * descendants. Called on `category.updated.v1`: a renamed or deactivated
335
+ * category changes the `categorySlugs` projection of its products, and a
336
+ * stale projection keeps a hidden category working as a PLP filter.
337
+ *
338
+ * The subtree walk and the de-duplication both belong to `catalog`, which
339
+ * owns `categories` and `product_categories`; this module used to run the
340
+ * recursive query itself. Returns the number of products re-indexed.
341
+ */
342
+ async reindexCategorySubtree(em, categoryId) {
343
+ const productIds = await this.categories.listProductIdsInSubtree(categoryId);
344
+ for (const productId of productIds) {
345
+ await this.upsertProduct(em, productId);
346
+ }
347
+ return productIds.length;
348
+ }
349
+ /**
350
+ * Drop a product from every channel index. Called on
351
+ * `product.archived.v1` and on hard delete.
352
+ */
353
+ async deleteProduct(em, productId) {
354
+ const channels = await em.find(SalesChannel, {});
355
+ const touched = [];
356
+ for (const channel of channels) {
357
+ const indexUid = indexUidFor(channel);
358
+ const index = await this.ensureIndex(indexUid);
359
+ const task = await index.deleteDocument(productId);
360
+ await this.awaitTask(task, `deletion of product ${productId} from ${indexUid}`);
361
+ touched.push(channel.code);
362
+ }
363
+ return touched;
364
+ }
365
+ /**
366
+ * Re-apply searchable + filterable attribute settings on every channel
367
+ * index, derived from the live `product_attributes` rows. Called on
368
+ * `attribute.updated.v1` so a flipped `isFilterable` / `isSearchable`
369
+ * propagates without a full reindex.
370
+ *
371
+ * It reapplies {@link SORTABLE_ATTRIBUTES} too, which is what lets an index
372
+ * built before issue #287 regain `name` sorting without waiting for a full
373
+ * reindex — the documents already carry the field. `createdAt` does not: a
374
+ * field that was never written has to be reindexed in.
375
+ */
376
+ async refreshAttributeSettings(em) {
377
+ const channels = await em.find(SalesChannel, {});
378
+ // Feature 012 — keep `searchableOptions` in the searchable list so
379
+ // toggling isSearchable on a select-style attribute takes effect
380
+ // without a full reindex. The aggregated field stays in the index
381
+ // documents from the previous reindex; settings refresh just opts
382
+ // it back into the search rank.
383
+ const { searchable, filterable } = await this.attributeSettings();
384
+ const touched = [];
385
+ for (const channel of channels) {
386
+ const indexUid = indexUidFor(channel);
387
+ const index = await this.ensureIndex(indexUid);
388
+ const searchableTask = await index.updateSearchableAttributes(searchable);
389
+ const filterableTask = await index.updateFilterableAttributes(filterable);
390
+ const sortableTask = await index.updateSortableAttributes([...SORTABLE_ATTRIBUTES]);
391
+ // Settings updates are async tasks; wait so callers reading the
392
+ // settings immediately after see the new values.
393
+ // Unlike the identical-looking three in `reindexChannel`, these land on
394
+ // a **populated** index, and a settings update re-indexes the corpus it
395
+ // lands on — so these are O(corpus) where those are O(1). It is the
396
+ // sharpest illustration of why the old per-site literals were not a
397
+ // considered set: the three cheapest waits and the three most expensive
398
+ // ones both took the client default by omission.
399
+ await this.awaitTask(searchableTask, `searchable attributes for ${indexUid}`);
400
+ await this.awaitTask(filterableTask, `filterable attributes for ${indexUid}`);
401
+ await this.awaitTask(sortableTask, `sortable attributes for ${indexUid}`);
402
+ touched.push(channel.code);
403
+ }
404
+ return touched;
405
+ }
406
+ async reindexAllChannels(em) {
407
+ const channels = await em.find(SalesChannel, {});
408
+ const results = [];
409
+ for (const channel of channels) {
410
+ const summary = await this.reindexChannel(em, channel);
411
+ results.push({
412
+ channelCode: channel.code,
413
+ indexUid: summary.indexUid,
414
+ documentCount: summary.documentCount,
415
+ });
416
+ }
417
+ return results;
418
+ }
419
+ /**
420
+ * Attach a Meilisearch embedder to a single channel index — feature 006 / T026.
421
+ *
422
+ * Wires Meilisearch's hybrid lexical + semantic search ("AI-augmented
423
+ * search"). Once attached, queries automatically blend keyword and
424
+ * vector scoring; the storefront does not change behaviour beyond
425
+ * ranking. We register the embedder under the well-known name
426
+ * `default` so callers do not have to thread the name through.
427
+ *
428
+ * Uses the `openAi` embedder source — accepts a custom `url`, so it
429
+ * works against any OpenAI-compatible endpoint (OpenAI itself, Azure
430
+ * OpenAI, Ollama's OpenAI shim, etc.). When the operator needs a
431
+ * non-OpenAI-shaped provider, the manifest can later expose
432
+ * `search.llm.embedder_source` with a discriminated union; out of
433
+ * scope for the MVP toggle.
434
+ */
435
+ async attachEmbedderForChannel(channelCode, config) {
436
+ const indexUid = indexUidFor({ code: channelCode });
437
+ const index = await this.ensureIndex(indexUid);
438
+ const task = await index.updateEmbedders({
439
+ default: {
440
+ source: 'openAi',
441
+ url: config.url,
442
+ apiKey: config.apiKey,
443
+ model: config.model,
444
+ },
445
+ });
446
+ // This site and `detachEmbedderForChannel` were the two that already
447
+ // carried a literal, `{ timeout: 30_000 }`, for the stated reason that
448
+ // Meilisearch may validate the embedder URL server-side. The reason was
449
+ // right and the remedy was local: nine sibling waits kept the 5 s default
450
+ // for no reason at all. Both now take the module-wide value, which is
451
+ // larger than the 30 s they asked for, so neither loses anything.
452
+ await this.awaitTask(task, `embedder attach on ${indexUid}`);
453
+ }
454
+ /**
455
+ * Detach the embedder from a single channel index — feature 006 / T026.
456
+ * Reverses {@link attachEmbedderForChannel} so the channel falls back
457
+ * to plain lexical ranking.
458
+ */
459
+ async detachEmbedderForChannel(channelCode) {
460
+ const indexUid = indexUidFor({ code: channelCode });
461
+ const index = await this.ensureIndex(indexUid);
462
+ const task = await index.resetEmbedders();
463
+ await this.awaitTask(task, `embedder detach on ${indexUid}`);
464
+ }
465
+ /**
466
+ * Feature 012 / FR-035 — build a per-call lookup of (attributeKey,
467
+ * value) → resolved per-locale option label, restricted to attributes
468
+ * that are isSearchable AND of a select-style type. Used by
469
+ * buildDocument() to fill the `searchableOptions` array on each
470
+ * indexed document so storefront search hits the customer-visible
471
+ * label, not the raw option value.
472
+ *
473
+ * The lookup is constructed in two queries (attribute set + option
474
+ * set) so the per-product loop stays O(1).
475
+ */
476
+ async loadSearchableOptionLookup(_em, locale) {
477
+ // Feature 061 — the option labels come from the composed view (backed by
478
+ // `custom_field_options`), restricted to isSearchable select-style
479
+ // attributes; no raw SQL against catalog storage.
480
+ const out = new Map();
481
+ const attrs = (await this.attributeRead.listByFlag('isSearchable')).filter((a) => a.valueType === 'select' || a.valueType === 'enum' || a.valueType === 'multiselect');
482
+ if (attrs.length === 0)
483
+ return out;
484
+ for (const attr of attrs) {
485
+ let bucket = out.get(attr.key);
486
+ if (!bucket) {
487
+ bucket = new Map();
488
+ out.set(attr.key, bucket);
489
+ }
490
+ for (const o of attr.options) {
491
+ const labelMap = o.label ?? {};
492
+ const rendered = labelMap[locale] ??
493
+ labelMap[FALLBACK_LOCALE] ??
494
+ Object.values(labelMap)[0] ??
495
+ o.labelDefault ??
496
+ o.value;
497
+ bucket.set(o.value, rendered);
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+ /** Feature 061 — derive per-index searchable/filterable settings from the view. */
503
+ async attributeSettings() {
504
+ const attributes = await this.attributeRead.listAll();
505
+ // Feature 012 — `searchableOptions` is the rendered per-locale option
506
+ // label aggregator for every isSearchable select-style attribute. It
507
+ // joins the customer's mental model ("brass") with the operator's
508
+ // canonical option value ("brass_001" / "Mosiądz").
509
+ const searchable = ['name', 'sku', 'description', 'searchableOptions'];
510
+ const filterable = ['categoryIds', 'categorySlugs', 'visibility', 'status'];
511
+ for (const attr of attributes) {
512
+ const path = `attributes.${attr.key}`;
513
+ if (attr.isSearchable)
514
+ searchable.push(path);
515
+ if (attr.isFilterable)
516
+ filterable.push(path);
517
+ }
518
+ return { searchable, filterable };
519
+ }
520
+ async ensureIndex(uid) {
521
+ try {
522
+ return await this.client.getIndex(uid);
523
+ }
524
+ catch {
525
+ const task = await this.client.createIndex(uid, { primaryKey: 'id' });
526
+ // `index_already_exists` is tolerated, and it is the one site in this
527
+ // file where a failed task is an expected outcome rather than a defect:
528
+ // this method is a check-then-create with no lock, so two processes
529
+ // indexing the same channel both miss `getIndex` and both create. The
530
+ // loser's task fails and the index is there, which is the whole
531
+ // postcondition. Measured: a second `createIndex` returns a task with
532
+ // `status: 'failed'`, `code: 'index_already_exists'`.
533
+ await this.awaitTask(task, `index create for ${uid}`, ['index_already_exists']);
534
+ return this.client.getIndex(uid);
535
+ }
536
+ }
537
+ /**
538
+ * Every product id the channel carries, not the first page of them.
539
+ * `listEntityIdsForChannel` is paginated and a full reindex wants the whole
540
+ * assortment, so the pages are walked to `total`. `total` is re-read on each
541
+ * call and the loop is bounded by it, so a concurrent membership write cannot
542
+ * spin it — the shape `seo`'s sitemap generator settled on for this read.
543
+ */
544
+ async channelMemberProductIds(channelId) {
545
+ const ids = new Set();
546
+ for (let page = 0;; page += 1) {
547
+ const { entityIds, total } = await this.channelMembership.listEntityIdsForChannel(channelId, 'product', page, MEMBERSHIP_PAGE_SIZE);
548
+ for (const id of entityIds)
549
+ ids.add(id);
550
+ if (entityIds.length === 0 || ids.size >= total)
551
+ return [...ids];
552
+ }
553
+ }
554
+ }
555
+ /**
556
+ * What a channel index carries. Archived and soft-deleted rows are excluded
557
+ * here and not by `CatalogProductLookupOptions`, whose `activeOnly` covers
558
+ * `status` and `deletedAt` but not `archivedAt` — and an archived product must
559
+ * not stay searchable.
560
+ */
561
+ function isPublishable(product) {
562
+ return product.status === 'active' && !product.deletedAt && !product.archivedAt;
563
+ }
564
+ export function indexUidFor(channel) {
565
+ return `products_${channel.code.replace(/[^a-z0-9_]/gi, '_').toLowerCase()}`;
566
+ }
567
+ function buildDocument(product, categories, locale, searchableOptionLookup, resolverInputs) {
568
+ // Feature 022 — when resolver inputs are supplied, route the system
569
+ // Name / Description through the four-step fallback chain so
570
+ // per-(channel, language) overrides are visible in search. When not
571
+ // supplied (legacy callers), fall back to plain locale-pick.
572
+ let name = pickLocale(product.name, locale);
573
+ let description = pickLocale(product.description, locale);
574
+ if (resolverInputs) {
575
+ const overrideRows = resolverInputs.overrides.map((o) => ({
576
+ attributeKey: o.attributeKey,
577
+ channelId: o.channelId,
578
+ languageCode: o.languageCode ?? null,
579
+ value: o.value,
580
+ }));
581
+ const ctx = {
582
+ channelId: resolverInputs.channelId,
583
+ languageCode: resolverInputs.languageCode,
584
+ primaryLanguage: resolverInputs.languageCode,
585
+ };
586
+ const resolvedName = resolveAttribute({
587
+ attributeKey: 'name',
588
+ baseline: product.name,
589
+ overrides: overrideRows,
590
+ scope: SYSTEM_ATTRIBUTE_SCOPES.name,
591
+ ctx,
592
+ });
593
+ const resolvedDesc = resolveAttribute({
594
+ attributeKey: 'description',
595
+ baseline: product.description,
596
+ overrides: overrideRows,
597
+ scope: SYSTEM_ATTRIBUTE_SCOPES.description,
598
+ ctx,
599
+ });
600
+ if (typeof resolvedName.value === 'string' && resolvedName.value.length > 0) {
601
+ name = resolvedName.value;
602
+ }
603
+ if (typeof resolvedDesc.value === 'string' && resolvedDesc.value.length > 0) {
604
+ description = resolvedDesc.value;
605
+ }
606
+ }
607
+ const attrs = {};
608
+ // Feature 012 — collect resolved per-locale option labels for every
609
+ // searchable select-style attribute the product carries a value for.
610
+ const searchableOptions = [];
611
+ for (const [key, value] of Object.entries(product.attributeValues)) {
612
+ if (value === null || value === undefined) {
613
+ attrs[key] = null;
614
+ continue;
615
+ }
616
+ const optionLabels = searchableOptionLookup.get(key);
617
+ if (Array.isArray(value)) {
618
+ // multiselect — array of option values.
619
+ if (optionLabels) {
620
+ for (const item of value) {
621
+ const rendered = optionLabels.get(String(item));
622
+ if (rendered)
623
+ searchableOptions.push(rendered);
624
+ }
625
+ }
626
+ // Persist the raw shape too so non-search filtering still works
627
+ // (Meilisearch tolerates JSON-string fallback per the comment
628
+ // in the original implementation).
629
+ attrs[key] = JSON.stringify(value);
630
+ continue;
631
+ }
632
+ if (typeof value === 'string' ||
633
+ typeof value === 'number' ||
634
+ typeof value === 'boolean') {
635
+ attrs[key] = value;
636
+ if (optionLabels && typeof value === 'string') {
637
+ const rendered = optionLabels.get(value);
638
+ if (rendered)
639
+ searchableOptions.push(rendered);
640
+ }
641
+ continue;
642
+ }
643
+ // Fall through for unexpected shapes — coerce to JSON string so the
644
+ // document still upserts. The query layer can ignore these.
645
+ attrs[key] = JSON.stringify(value);
646
+ }
647
+ // We also surface `categorySlugs` so the typed contract layer can filter
648
+ // by `categorySlug` without a join.
649
+ return {
650
+ id: product.id,
651
+ sku: product.sku,
652
+ name,
653
+ description,
654
+ type: product.type,
655
+ status: product.status,
656
+ visibility: product.visibility,
657
+ slug: product.slug,
658
+ primaryAssetUrl: null,
659
+ categoryIds: categories.map((c) => c.id),
660
+ categorySlugs: categories.map((c) => c.slug),
661
+ attributes: attrs,
662
+ searchableOptions,
663
+ createdAt: product.createdAt.getTime(),
664
+ updatedAt: product.updatedAt.getTime(),
665
+ };
666
+ }
667
+ function pickLocale(value, locale) {
668
+ return (value[locale] ?? value[FALLBACK_LOCALE] ?? Object.values(value)[0] ?? '');
669
+ }
670
+ //# sourceMappingURL=search-indexer.js.map