@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/backend/cli/reindex.d.ts +4 -0
- package/dist/backend/cli/reindex.d.ts.map +1 -0
- package/dist/backend/cli/reindex.js +32 -0
- package/dist/backend/cli/reindex.js.map +1 -0
- package/dist/backend/entities/search-phrase-record.entity.d.ts +40 -0
- package/dist/backend/entities/search-phrase-record.entity.d.ts.map +1 -0
- package/dist/backend/entities/search-phrase-record.entity.js +85 -0
- package/dist/backend/entities/search-phrase-record.entity.js.map +1 -0
- package/dist/backend/index.d.ts +87 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +153 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +133 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +250 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +28 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +33 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.public.d.ts +22 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +192 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/services/embedder-config-resolver.d.ts +25 -0
- package/dist/backend/services/embedder-config-resolver.d.ts.map +1 -0
- package/dist/backend/services/embedder-config-resolver.js +47 -0
- package/dist/backend/services/embedder-config-resolver.js.map +1 -0
- package/dist/backend/services/llm-toggle.service.d.ts +50 -0
- package/dist/backend/services/llm-toggle.service.d.ts.map +1 -0
- package/dist/backend/services/llm-toggle.service.js +78 -0
- package/dist/backend/services/llm-toggle.service.js.map +1 -0
- package/dist/backend/services/search-event-subscriber.d.ts +125 -0
- package/dist/backend/services/search-event-subscriber.d.ts.map +1 -0
- package/dist/backend/services/search-event-subscriber.js +113 -0
- package/dist/backend/services/search-event-subscriber.js.map +1 -0
- package/dist/backend/services/search-indexer.d.ts +346 -0
- package/dist/backend/services/search-indexer.d.ts.map +1 -0
- package/dist/backend/services/search-indexer.js +670 -0
- package/dist/backend/services/search-indexer.js.map +1 -0
- package/dist/backend/services/search-phrase-recorder.service.d.ts +25 -0
- package/dist/backend/services/search-phrase-recorder.service.d.ts.map +1 -0
- package/dist/backend/services/search-phrase-recorder.service.js +89 -0
- package/dist/backend/services/search-phrase-recorder.service.js.map +1 -0
- package/dist/backend/services/search-query-port.d.ts +29 -0
- package/dist/backend/services/search-query-port.d.ts.map +1 -0
- package/dist/backend/services/search-query-port.js +20 -0
- package/dist/backend/services/search-query-port.js.map +1 -0
- package/dist/backend/services/search-query.service.d.ts +205 -0
- package/dist/backend/services/search-query.service.d.ts.map +1 -0
- package/dist/backend/services/search-query.service.js +346 -0
- package/dist/backend/services/search-query.service.js.map +1 -0
- package/dist/backend/services/search-reindex-worker.d.ts +27 -0
- package/dist/backend/services/search-reindex-worker.d.ts.map +1 -0
- package/dist/backend/services/search-reindex-worker.js +13 -0
- package/dist/backend/services/search-reindex-worker.js.map +1 -0
- package/dist/backend/services/search-suggest.service.d.ts +53 -0
- package/dist/backend/services/search-suggest.service.d.ts.map +1 -0
- package/dist/backend/services/search-suggest.service.js +64 -0
- package/dist/backend/services/search-suggest.service.js.map +1 -0
- package/dist/backend/services/suggestion-pricing-enricher.d.ts +62 -0
- package/dist/backend/services/suggestion-pricing-enricher.d.ts.map +1 -0
- package/dist/backend/services/suggestion-pricing-enricher.js +41 -0
- package/dist/backend/services/suggestion-pricing-enricher.js.map +1 -0
- package/dist/manifest.d.ts +263 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +300 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260501T123145_search_phrase_records_init.d.ts +13 -0
- package/dist/migrations/20260501T123145_search_phrase_records_init.d.ts.map +1 -0
- package/dist/migrations/20260501T123145_search_phrase_records_init.js +40 -0
- package/dist/migrations/20260501T123145_search_phrase_records_init.js.map +1 -0
- package/dist/migrations/index.d.ts +27 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +29 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/search.md +284 -0
- package/i18n/en.json +10 -0
- package/i18n/pl.json +10 -0
- 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
|