ReferenceCollections & search

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).

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.

MethodPathPurpose
GET/v1/tenants/:tenant/catalog/searchFull-text product search with facets.

Query parameters:

ParamMeaning
qThe search query. Blank/whitespace = “no query” (empty 200).
limit / offsetPagination, clamped to safe bounds.
min_price_cents / max_price_centsFaceted price filter (integer cents). A non-integer or negative value is treated as unset, never a silently-scoped 0.
collectionFilter 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.

MethodPathPurpose
GET/v1/tenants/:tenant/catalog/collectionsList a store’s collections (slug, title, description).
GET/v1/tenants/:tenant/catalog/collections/:slugFetch one collection + its active products (with variants).

Collections — operator management

x-operator-token, mounted under /v1/tenants. Each write is audited.

MethodPathPurpose
POST/v1/tenants/:tenant/collectionsCreate a collection (slug + title required).
GET/v1/tenants/:tenant/collectionsList collections (full rows).
GET/v1/tenants/:tenant/collections/:idFetch one collection + its products.
POST/v1/tenants/:tenant/collections/:id/productsAdd a product to a collection (201 if newly added, 200 if already a member).
DELETE/v1/tenants/:tenant/collections/:id/products/:productIdRemove a product from a collection (404 if not a member).

Events

DirectionEventNotes
Publishescommerce.product.searched.v1Fire-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.