@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 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,+CAA+C,EAAE,MAAM,iDAAiD,CAAC;AAElH,eAAO,MAAM,UAAU,4DAEtB,CAAC;AAEF,OAAO,EACL,+CAA+C,GAChD,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent (D-168).
|
|
8
|
+
*
|
|
9
|
+
* Listed in ascending timestamp, which is the order of this module's own
|
|
10
|
+
* migrations and of nothing else (feature 081): a manifest `dependencies` array
|
|
11
|
+
* is the only thing ordering this block against another module's.
|
|
12
|
+
*
|
|
13
|
+
* The **named** exports stay beside the array, and the asymmetry with
|
|
14
|
+
* `./backend` — which publishes an array and no named class (D-168) — is
|
|
15
|
+
* deliberate. `db/migrations-registry.generated.ts` imports each class by name
|
|
16
|
+
* from this specifier, and a migration class name is contract in a way an entity
|
|
17
|
+
* class name is not: `mikro_orm_migrations` persists it, so it is a string every
|
|
18
|
+
* already-migrated database holds.
|
|
19
|
+
*
|
|
20
|
+
* A class that is in neither the array nor the barrel is a migration that does
|
|
21
|
+
* not run: `migration:pending` reports nothing pending and the first symptom is
|
|
22
|
+
* a query against a table nobody created.
|
|
23
|
+
*/
|
|
24
|
+
import { Migration20260501T123145SearchPhraseRecordsInit } from './20260501T123145_search_phrase_records_init.js';
|
|
25
|
+
export const migrations = [
|
|
26
|
+
Migration20260501T123145SearchPhraseRecordsInit,
|
|
27
|
+
];
|
|
28
|
+
export { Migration20260501T123145SearchPhraseRecordsInit, };
|
|
29
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,+CAA+C,EAAE,MAAM,iDAAiD,CAAC;AAElH,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,+CAA+C;CAChD,CAAC;AAEF,OAAO,EACL,+CAA+C,GAChD,CAAC"}
|
package/docs/search.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: search
|
|
3
|
+
description: Meilisearch indexer + query bridge
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `search`
|
|
7
|
+
|
|
8
|
+
Meilisearch-backed catalog search. Owns the indexer that mirrors Catalog
|
|
9
|
+
events into per-Sales-Channel indexes, the typed query bridge, the
|
|
10
|
+
typeahead-popup feed, the analytics ingest for committed search phrases,
|
|
11
|
+
and the LLM-augmented-search opt-in.
|
|
12
|
+
|
|
13
|
+
The module composition root subscribes to Catalog events
|
|
14
|
+
(`product.created.v1`, `product.updated.v1`, `product.archived.v1`,
|
|
15
|
+
`attribute.updated.v1`) and to Settings events
|
|
16
|
+
(`settings.value_changed`) without imports into either module's
|
|
17
|
+
internals.
|
|
18
|
+
|
|
19
|
+
## Public surface
|
|
20
|
+
|
|
21
|
+
| Verb + Path | Audience | Purpose |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `GET /api/v1/search/suggest?q=…&limit=N` | storefront (no auth) | Typeahead popup feed; returns up to `limit` `ProductSummary` rows ordered by Meilisearch relevance |
|
|
24
|
+
| `POST /api/v1/search/record` | storefront (no auth) | Fire-and-forget analytics ingest; persists one verbatim row in `search_phrase_records` per committed search |
|
|
25
|
+
| `POST /api/v1/admin/search/llm/toggle` | admin (`search:write`) | Cross-setting-validating wrapper around `search.llm.enabled`; refuses to enable when any embedder.* field is empty for any targeted channel |
|
|
26
|
+
|
|
27
|
+
The full search results page reuses `GET /api/v1/catalog/products?q=…` —
|
|
28
|
+
catalog owns that contract, and the storefront `/search` route just
|
|
29
|
+
re-exports `CatalogPage`. There is no parallel results-page contract.
|
|
30
|
+
|
|
31
|
+
## Settings
|
|
32
|
+
|
|
33
|
+
Six knobs live under the `search` group, registered by
|
|
34
|
+
`packages/modules/search/src/manifest.ts`:
|
|
35
|
+
|
|
36
|
+
| Code | Type | Default | Purpose |
|
|
37
|
+
| --- | --- | --- | --- |
|
|
38
|
+
| `search.popup.suggestion_count` | `number` | `8` | How many products the storefront popup shows; `0` suppresses the popup but committed search still navigates |
|
|
39
|
+
| `search.popup.minimum_query_length` | `number` | `3` | Minimum characters before the storefront fires a suggest request and before the recorder persists a row |
|
|
40
|
+
| `search.llm.enabled` | `boolean` | `false` | Enables Meilisearch's hybrid lexical + semantic search on this channel |
|
|
41
|
+
| `search.llm.embedder_url` | `string` | `""` | OpenAI-compatible embedder endpoint URL (used by Meilisearch) |
|
|
42
|
+
| `search.llm.embedder_api_key` | `string` | `""` | Embedder API key (admin UI masks the input) |
|
|
43
|
+
| `search.llm.embedder_model` | `string` | `""` | Embedder model identifier (e.g. `text-embedding-3-small`) |
|
|
44
|
+
|
|
45
|
+
All six are sales-channel-scoped via the existing Settings model. The
|
|
46
|
+
storefront popup reads its count + threshold per request via the
|
|
47
|
+
universal getter (`SettingsService.get`); on any read error (cache
|
|
48
|
+
miss, `SettingNotRegistered`) the suggest service falls back to the
|
|
49
|
+
manifest defaults so a Settings hiccup never 500s the popup.
|
|
50
|
+
|
|
51
|
+
## LLM-augmented search
|
|
52
|
+
|
|
53
|
+
Toggling `search.llm.enabled` on for a channel attaches a Meilisearch
|
|
54
|
+
embedder to that channel's index. We register the embedder under the
|
|
55
|
+
well-known name `default` with `source: 'openAi'`; the `url` parameter
|
|
56
|
+
makes it work against any OpenAI-compatible endpoint (OpenAI itself,
|
|
57
|
+
Azure OpenAI, Ollama's OpenAI shim, …).
|
|
58
|
+
|
|
59
|
+
The toggle wrapper (`POST /api/v1/admin/search/llm/toggle`) refuses to
|
|
60
|
+
flip `enabled=true` for any channel whose three embedder.* fields
|
|
61
|
+
aren't all populated. The error envelope's `details[]` lists
|
|
62
|
+
every missing `(channelCode, settingCode)` pair so the admin UI can
|
|
63
|
+
highlight the gaps.
|
|
64
|
+
|
|
65
|
+
The reactor (in `SearchEventSubscriber`) is belt-and-braces: a power
|
|
66
|
+
user PUTting `search.llm.enabled=true` directly through the generic
|
|
67
|
+
Settings admin route (bypassing the wrapper) does **not** poison
|
|
68
|
+
Meilisearch — the reactor warn-logs and leaves the index alone if the
|
|
69
|
+
embedder.* triplet is incomplete.
|
|
70
|
+
|
|
71
|
+
Disabling never refuses; the reactor calls `index.resetEmbedders()` so
|
|
72
|
+
the channel falls back to plain lexical ranking.
|
|
73
|
+
|
|
74
|
+
Meilisearch v1.11 gates `embedders` behind the `vectorStore`
|
|
75
|
+
experimental feature; v1.13+ treats embedders as stable. Both are
|
|
76
|
+
supported transparently — the admin endpoint does not gate on the
|
|
77
|
+
Meilisearch version.
|
|
78
|
+
|
|
79
|
+
## Analytics ingest
|
|
80
|
+
|
|
81
|
+
Every committed storefront search lands one row in
|
|
82
|
+
`search_phrase_records` for the future Analytics module to aggregate:
|
|
83
|
+
|
|
84
|
+
| Column | Notes |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `phrase` | Verbatim — no typo correction or LLM expansion |
|
|
87
|
+
| `phrase_normalized` | `lower(trim(phrase))`, maintained at insert time; supports case-insensitive aggregation without rewriting the verbatim phrase |
|
|
88
|
+
| `sales_channel_id` | FK → `sales_channels.id` (`ON DELETE RESTRICT` — dropping a channel must surface as a deliberate decision rather than silently delete history) |
|
|
89
|
+
| `result_count` | Number of products the search returned; `0` for dead-end phrases |
|
|
90
|
+
| `recorded_at` | `timestamptz default now()` |
|
|
91
|
+
|
|
92
|
+
Indexes:
|
|
93
|
+
|
|
94
|
+
- `idx_search_phrase_records_aggr` on `(sales_channel_id, phrase_normalized, recorded_at desc)` — supports the analytics-module's "phrases by channel and frequency over a window" query.
|
|
95
|
+
- `idx_search_phrase_records_recent` on `(recorded_at desc)` — for ops dashboards.
|
|
96
|
+
|
|
97
|
+
The recorder is fire-and-forget (`SearchPhraseRecorder.record(...)`):
|
|
98
|
+
it returns once the row has been queued, not once persisted, and
|
|
99
|
+
swallows every exception via warn-log. The route hands off the
|
|
100
|
+
promise without awaiting (`void recorder.record(...)`) and returns
|
|
101
|
+
`202 { ok: true }` immediately. The storefront response is therefore
|
|
102
|
+
never delayed or failed by analytics persistence.
|
|
103
|
+
|
|
104
|
+
Below-threshold phrases (shorter than the channel's
|
|
105
|
+
`minimum_query_length`) are silently no-op'd server-side as a
|
|
106
|
+
belt-and-braces against a UI bug flooding the table with stub
|
|
107
|
+
phrases.
|
|
108
|
+
|
|
109
|
+
## Indexer + event subscriber
|
|
110
|
+
|
|
111
|
+
`SearchIndexer` writes one document per product into a per-channel
|
|
112
|
+
index named `products_<channel_code>`. Document shape carries the
|
|
113
|
+
catalog product surface (id, sku, name, description, type, status,
|
|
114
|
+
slug, primaryAssetUrl, categoryIds, categorySlugs, attributes,
|
|
115
|
+
searchableOptions, createdAt, updatedAt). The indexer also drives
|
|
116
|
+
Meilisearch's `searchableAttributes` + `filterableAttributes` from the
|
|
117
|
+
live `product_attributes.is_searchable` + `is_filterable` flags, and
|
|
118
|
+
its `sortableAttributes` from `SORTABLE_ATTRIBUTES` (see *Sorting*).
|
|
119
|
+
|
|
120
|
+
**A document carries no price.** It used to carry one, copied from the
|
|
121
|
+
legacy `attributes.defaultPrice` catalogue attribute — not any price
|
|
122
|
+
list's figure — and read by nothing on the query path, which resolves
|
|
123
|
+
the viewer's price from `price_lists` at hydrate time so the index
|
|
124
|
+
cannot decide what a buyer pays. A per-buyer price cannot be indexed
|
|
125
|
+
in any case: one index per sales channel and one document per product
|
|
126
|
+
means pricing the document would multiply the corpus by the customer
|
|
127
|
+
base. The legacy attribute is still indexed under
|
|
128
|
+
`attributes.defaultPrice`, where its name says what it is.
|
|
129
|
+
|
|
130
|
+
### Sorting
|
|
131
|
+
|
|
132
|
+
Meilisearch refuses a sort on any field outside the index's
|
|
133
|
+
`sortableAttributes`, so the fields the query path may sort on are
|
|
134
|
+
declared once, in `SORTABLE_ATTRIBUTES` (`search-indexer.ts`):
|
|
135
|
+
`createdAt` and `name`. That constant is both what the indexer applies
|
|
136
|
+
to every channel index and the alphabet `buildSort` may emit — its
|
|
137
|
+
return type is built from it, so a sort naming a field the indexer
|
|
138
|
+
never declared does not compile.
|
|
139
|
+
|
|
140
|
+
The listing contract's four sorts map as follows:
|
|
141
|
+
|
|
142
|
+
| `?sort=` | Meilisearch |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `relevance` (or absent) | none — the engine's ranking rules |
|
|
145
|
+
| `-createdAt` | `createdAt:desc` |
|
|
146
|
+
| `name` | `name:asc` |
|
|
147
|
+
| `-name` | `name:desc` |
|
|
148
|
+
|
|
149
|
+
The indexed `name` is resolved in the channel's own default language,
|
|
150
|
+
which is the language the listing renders, so the order a buyer sees
|
|
151
|
+
is the order they were sorted by.
|
|
152
|
+
|
|
153
|
+
**Upgrading an index built before the sort settings were applied** needs a reindex: the
|
|
154
|
+
settings were never applied (`sortableAttributes` was `[]` on every
|
|
155
|
+
index, and every sorted listing was silently answered by the Postgres
|
|
156
|
+
fallback), and `createdAt` was never written into a document. A full
|
|
157
|
+
reindex applies both. That happens automatically on the next periodic
|
|
158
|
+
sweep in the worker role (`search.reindex_interval_minutes`, default
|
|
159
|
+
10); a deployment that runs no sweep needs the operator step — the
|
|
160
|
+
admin **Reindex products** action or `pnpm --filter backend run
|
|
161
|
+
search:reindex`. An `attribute.updated.v1` refresh reapplies the
|
|
162
|
+
settings but writes no documents, so it restores `name` sorting and
|
|
163
|
+
not `-createdAt`.
|
|
164
|
+
|
|
165
|
+
### `searchableOptions` — option-list search
|
|
166
|
+
|
|
167
|
+
For attributes flagged `isSearchable` AND with a select-style
|
|
168
|
+
`valueType` (`select`, `enum`, `multiselect`), the indexer resolves
|
|
169
|
+
the per-locale option label and includes it in the document's
|
|
170
|
+
`searchableOptions: string[]` field. Meilisearch settings include
|
|
171
|
+
`searchableOptions` in `searchableAttributes` so a customer searching
|
|
172
|
+
for the rendered text they see (e.g. "Czerwony") hits products whose
|
|
173
|
+
raw value is the option key (e.g. "red"). Toggling `isSearchable` off
|
|
174
|
+
removes the option labels from `searchableOptions` on the next refresh
|
|
175
|
+
within the existing event-driven cadence.
|
|
176
|
+
|
|
177
|
+
`SearchEventSubscriber` keeps the indexes in sync:
|
|
178
|
+
|
|
179
|
+
| Event | Handler |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `product.created.v1` | `indexer.upsertProduct(productId)` |
|
|
182
|
+
| `product.updated.v1` | `indexer.upsertProduct(productId)` |
|
|
183
|
+
| `product.archived.v1` | `indexer.deleteProduct(productId)` |
|
|
184
|
+
| `attribute.updated.v1` | `indexer.refreshAttributeSettings()` |
|
|
185
|
+
| `settings.value_changed` (when `settingCode === 'search.llm.enabled'`) | `indexer.attachEmbedderForChannel` / `detachEmbedderForChannel` |
|
|
186
|
+
|
|
187
|
+
All handlers swallow their own errors — a transient Meilisearch
|
|
188
|
+
outage does not break the catalog write path. The reserved fallback
|
|
189
|
+
in the read path (`catalog/routes.public.ts`) keeps storefront search
|
|
190
|
+
functional with a stale index until the next offline `search:reindex`.
|
|
191
|
+
|
|
192
|
+
### When the index does not answer
|
|
193
|
+
|
|
194
|
+
The fallback stays a fallback — a public catalogue must not 503
|
|
195
|
+
because search is unhappy — but it distinguishes two facts that used
|
|
196
|
+
to be fused into one:
|
|
197
|
+
|
|
198
|
+
- **unreachable** — the engine is down, unroutable or timing out.
|
|
199
|
+
Transient. `catalog` logs it per request and re-runs the listing
|
|
200
|
+
through Postgres; nothing else is expected of anybody.
|
|
201
|
+
- **refused** — the engine answered, in milliseconds, that it will not
|
|
202
|
+
run *this* query: `invalid_search_sort`, `index_not_found`, a
|
|
203
|
+
rejected API key. Deterministic, will not pass on its own, and a
|
|
204
|
+
defect in how this module configured the index. The reason string
|
|
205
|
+
the caller logs names Meilisearch's own error code, and `search`
|
|
206
|
+
reports it itself at `error` level — once per code, because an index
|
|
207
|
+
setting is a deployment fact and a public listing would otherwise
|
|
208
|
+
report it on every request.
|
|
209
|
+
|
|
210
|
+
That distinction is the whole reason the defect could survive on five
|
|
211
|
+
live indexes: every sorted listing was falling back to Postgres, and
|
|
212
|
+
the only trace of it was a line reading `meilisearch unavailable`
|
|
213
|
+
about an engine that was up and healthy.
|
|
214
|
+
|
|
215
|
+
## Storefront integration
|
|
216
|
+
|
|
217
|
+
The page header (`storefront/components/Header.tsx`) renders a
|
|
218
|
+
`<form action="/search" method="GET">` with a progressively-enhanced
|
|
219
|
+
`<SearchAutocomplete>` client component inside it:
|
|
220
|
+
|
|
221
|
+
- 200 ms debounce; AbortController cancels stale fetches.
|
|
222
|
+
- Below the channel's `minimum_query_length`, no request fires.
|
|
223
|
+
- Keyboard model: ArrowUp/Down to move selection, Enter to navigate
|
|
224
|
+
to the selected suggestion, Escape to close, click outside to
|
|
225
|
+
dismiss.
|
|
226
|
+
- 503 from `/search/suggest` surfaces a "search temporarily
|
|
227
|
+
unavailable" item in the popup without breaking the static form.
|
|
228
|
+
- With JS disabled, the form posts `?q=…` to `/search` natively
|
|
229
|
+
(no JS required for crawlability).
|
|
230
|
+
|
|
231
|
+
The `/search` page (`storefront/app/(catalog)/search/page.tsx`)
|
|
232
|
+
re-exports `CatalogPage` — the listing, filters, sort, pagination,
|
|
233
|
+
and empty state are identical to `/catalog`. The only search-specific
|
|
234
|
+
behaviour is the analytics fire-and-forget: when `?q=` is present,
|
|
235
|
+
the page awaits `listProducts` (so `resultCount` is meaningful), then
|
|
236
|
+
`void recordPhrase(...)` BEFORE delegating to `CatalogPage`.
|
|
237
|
+
|
|
238
|
+
## Reindex CLI
|
|
239
|
+
|
|
240
|
+
`search reindex` walks every Sales Channel and pushes its public
|
|
241
|
+
product surface into Meilisearch. Idempotent — safe to run after a
|
|
242
|
+
fresh `endora demo seed` or whenever the index drifts from Postgres.
|
|
243
|
+
|
|
244
|
+
It is a command this module declares in its `manifest.ts` and the host
|
|
245
|
+
runs, so it reindexes through the one `SearchIndexer` the composition
|
|
246
|
+
holds rather than building a second one:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
pnpm --filter backend run search:reindex
|
|
250
|
+
# or, addressing the host binary directly:
|
|
251
|
+
pnpm --filter backend exec tsx src/cli.ts search reindex
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The body is `packages/modules/search/src/backend/cli/reindex.ts`.
|
|
255
|
+
|
|
256
|
+
## Testing
|
|
257
|
+
|
|
258
|
+
- `backend/test/contract/search/public-suggest.contract.test.ts` (7 cases) — happy path, limit override, threshold, oversize, missing q, limit OOB.
|
|
259
|
+
- `backend/test/contract/search/public-record.contract.test.ts` (7 cases) — happy path, default `result_count`, channel pinning, empty/oversize/negative validation, below-threshold no-op.
|
|
260
|
+
- `backend/test/contract/search/admin-llm-toggle.contract.test.ts` (5 cases) — incomplete-config refusal (every embedder.* permutation), full-config success, disable always succeeds, unauthenticated → 401.
|
|
261
|
+
- `backend/test/integration/search/manifest-reconcile.test.ts` (2 cases) — group + 6 settings seeded with correct defaults; idempotent re-apply.
|
|
262
|
+
- `backend/test/integration/search/embedder-reactor.test.ts` (3 cases) — attach on enable=true, detach on flip-back-to-false, no event-fire when wrapper refuses the toggle.
|
|
263
|
+
- `backend/test/integration/search/event-subscriber.test.ts` (existing) — catalog-event-driven indexing.
|
|
264
|
+
- `backend/test/integration/search/catalog-via-meilisearch.test.ts` (existing) — env-flag-dispatched read path.
|
|
265
|
+
- `backend/test/integration/search/sort-order.test.ts` (6 cases) — each of the four sorts served by Meilisearch (`x-search-backend` is the load-bearing assertion: Postgres answers all four, so the order alone cannot tell a served page from a fallback), the declared `sortableAttributes`, and the absence of the indexed `price`.
|
|
266
|
+
- `backend/test/unit/search/search-sort-attributes.test.ts` (4 cases) — `buildSort` emits only fields `SORTABLE_ATTRIBUTES` declares.
|
|
267
|
+
- `backend/test/unit/search/search-degrade-observability.test.ts` (3 cases) — a refused query names the engine's error code and is reported once; an unreachable one is not reported twice.
|
|
268
|
+
|
|
269
|
+
The last two need no services; the rest run against real Postgres +
|
|
270
|
+
real Meilisearch.
|
|
271
|
+
|
|
272
|
+
## Extension points
|
|
273
|
+
|
|
274
|
+
- **Custom rankers** — Meilisearch supports custom ranking rules per
|
|
275
|
+
index; the indexer can push the rule set when the admin UI grows a
|
|
276
|
+
per-channel weighting affordance.
|
|
277
|
+
- **Embedder-source enum** — `search.llm.embedder_source` with values
|
|
278
|
+
`openAi | huggingFace | rest | userProvided` would let a single
|
|
279
|
+
channel pick a non-OpenAI-compatible provider without renaming the
|
|
280
|
+
three credential settings.
|
|
281
|
+
- **Reserved fallback** — already implemented: when Meilisearch is
|
|
282
|
+
unavailable, the catalog read path degrades to Postgres ILIKE
|
|
283
|
+
search via `catalog-query.service.ts` so the storefront is never
|
|
284
|
+
fully broken.
|
package/i18n/en.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors.QUERY_TOO_SHORT": "Query Too Short.",
|
|
3
|
+
"errors.QUERY_TOO_LONG": "Query Too Long.",
|
|
4
|
+
"errors.LIMIT_OUT_OF_RANGE": "Limit Out Of Range.",
|
|
5
|
+
"errors.SEARCH_BACKEND_UNAVAILABLE": "Search Backend Unavailable.",
|
|
6
|
+
"errors.PHRASE_REQUIRED": "Phrase Required.",
|
|
7
|
+
"errors.PHRASE_TOO_LONG": "Phrase Too Long.",
|
|
8
|
+
"errors.RESULT_COUNT_INVALID": "Result Count Invalid.",
|
|
9
|
+
"errors.LLM_CONFIG_INCOMPLETE": "LLM search configuration is incomplete."
|
|
10
|
+
}
|
package/i18n/pl.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors.QUERY_TOO_SHORT": "Błąd: query too short.",
|
|
3
|
+
"errors.QUERY_TOO_LONG": "Błąd: query too long.",
|
|
4
|
+
"errors.LIMIT_OUT_OF_RANGE": "Błąd: limit out of range.",
|
|
5
|
+
"errors.SEARCH_BACKEND_UNAVAILABLE": "Błąd: search backend unavailable.",
|
|
6
|
+
"errors.PHRASE_REQUIRED": "Błąd: phrase required.",
|
|
7
|
+
"errors.PHRASE_TOO_LONG": "Błąd: phrase too long.",
|
|
8
|
+
"errors.RESULT_COUNT_INVALID": "Błąd: result count invalid.",
|
|
9
|
+
"errors.LLM_CONFIG_INCOMPLETE": "Konfiguracja wyszukiwania LLM jest niekompletna."
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@endora-commerce/mod-search",
|
|
3
|
+
"version": "0.100.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Per-channel Meilisearch indexes, suggest popup, and optional LLM-augmented search.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"endora": {
|
|
9
|
+
"type": "module",
|
|
10
|
+
"id": "search"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/endora-commerce/endora-commerce.git",
|
|
15
|
+
"directory": "packages/modules/search"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/manifest.d.ts",
|
|
23
|
+
"default": "./dist/manifest.js"
|
|
24
|
+
},
|
|
25
|
+
"./backend": {
|
|
26
|
+
"types": "./dist/backend/index.d.ts",
|
|
27
|
+
"default": "./dist/backend/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./migrations": {
|
|
30
|
+
"types": "./dist/migrations/index.d.ts",
|
|
31
|
+
"default": "./dist/migrations/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"i18n",
|
|
38
|
+
"docs"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.18.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@mikro-orm/core": "^6",
|
|
45
|
+
"@mikro-orm/migrations": "^6",
|
|
46
|
+
"@mikro-orm/postgresql": "^6",
|
|
47
|
+
"fastify": "^5",
|
|
48
|
+
"meilisearch": "^0",
|
|
49
|
+
"zod": "^4",
|
|
50
|
+
"@endora-commerce/platform": "0.100.0",
|
|
51
|
+
"@endora-commerce/contracts": "0.100.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@mikro-orm/core": "^6.6.13",
|
|
55
|
+
"@mikro-orm/migrations": "^6.6.13",
|
|
56
|
+
"@mikro-orm/postgresql": "^6.6.13",
|
|
57
|
+
"@types/node": "^22.9.0",
|
|
58
|
+
"fastify": "^5.12.5",
|
|
59
|
+
"meilisearch": "^0.57.0",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"vitest": "^4.1.11",
|
|
62
|
+
"zod": "^4.2.0",
|
|
63
|
+
"@endora-commerce/contracts": "0.100.0",
|
|
64
|
+
"@endora-commerce/platform": "0.100.0"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.build.json",
|
|
68
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
69
|
+
"lint": "eslint src",
|
|
70
|
+
"test": "vitest run"
|
|
71
|
+
}
|
|
72
|
+
}
|