Collections & faceted search
Two discovery surfaces added to the commerce module’s
public catalog: faceted search and collections. Both are read over the same public, unauthenticated catalog surface as
GET /v1/tenants/:tenant/catalog/products — no operator token, no tenant
credential — and both are gated on the store being live (requireLiveStore
— an unknown/non-live tenant 404s identically, no slug enumeration) and on
products being status='active'. A failed read is always a 503, never an
empty/absent 2xx (absence-claim rule).
Faceted search
Ranking and partial-token matching live in the repo (searchProducts) over
migration 0006’s tsvector + trigram indexes. An empty/whitespace q is a
valid request meaning “no query” — it returns an empty result set with a
200 and emits no analytics event; a DB failure is a real 503.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/tenants/:tenant/catalog/search | Full-text product search with facets. |
Query parameters:
| Param | Meaning |
|---|---|
q | The search query. Blank/whitespace = “no query” (empty 200). |
limit / offset | Pagination, clamped to safe bounds. |
min_price_cents / max_price_cents | Faceted price filter (integer cents). A non-integer or negative value is treated as unset, never a silently-scoped 0. |
collection | Filter results to a collection slug. |
The response carries query, result_count, limit, offset, the echoed
filters, a facets object (price_buckets + collections), and the
matched products (each with its variants). On a successful search it also
fires the commerce.product.searched.v1 signal (see Events).
Collections — public
A collection has no status column of its own; the store-live gate + the
status='active' product filter are its visibility contract.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/tenants/:tenant/catalog/collections | List a store’s collections (slug, title, description). |
GET | /v1/tenants/:tenant/catalog/collections/:slug | Fetch one collection + its active products (with variants). |
Collections — operator management
x-operator-token, mounted under /v1/tenants. Each write is audited.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/tenants/:tenant/collections | Create a collection (slug + title required). |
GET | /v1/tenants/:tenant/collections | List collections (full rows). |
GET | /v1/tenants/:tenant/collections/:id | Fetch one collection + its products. |
POST | /v1/tenants/:tenant/collections/:id/products | Add a product to a collection (201 if newly added, 200 if already a member). |
DELETE | /v1/tenants/:tenant/collections/:id/products/:productId | Remove a product from a collection (404 if not a member). |
Events
| Direction | Event | Notes |
|---|---|---|
| Publishes | commerce.product.searched.v1 | Fire-and-forget search-analytics signal. The in-process dispatcher has no consumer registered for it — it is an allowlisted external-analytics-sink signal, so publish just validates the name and returns; awaiting it cannot fail the search read. |
Manifest gates
behavior.search.enabled— the store search gate. Default true (search exists unless a store opts out); when disabled the header box renders as a plain form.behavior.search.typeahead— default true.
Collections have no dedicated manifest toggle — they render as part of the catalog surface when a store has any.
Seams (honest notes)
- The search-analytics event has no in-repo consumer — it is an external sink’s signal, deliberately allowlisted fire-and-forget, not dead wiring.
- Collection visibility is derived (store-live + active-product), not a per-collection publish/draft flag — there is no collection status column.