Search Module
Storefront-facing faceted product search and autocomplete, plus an admin-side bulk reindex trigger. Backed by Typesense via a provider-neutral SEARCH_PORT; the adapter is…
Storefront-facing faceted product search and autocomplete, plus an admin-side bulk reindex trigger. Backed by Typesense via a provider-neutral SEARCH_PORT; the adapter is swappable.
Source:
api-modules/search(registered viaSearchModule.forRoot({ provider: "typesense", ... })inapps/api/src/app.module.ts).
Conventions
Authentication
| Surface | Auth |
|---|---|
GET /store/product-search | Unauthenticated (public storefront) |
GET /store/product-search/suggestions | Unauthenticated |
POST /admin/search/reindex | Better-Auth bearer session + search:reindex permission |
Authorization: Bearer <session-token>The admin endpoint is protected by BetterAuthGuard + PermissionsGuard. The search resource exposes the actions:
| Resource | Actions |
|---|---|
search | reindex |
Grant search:reindex to whichever admin role should be allowed to trigger backfills.
Response envelope
Identical to the rest of the API — ResponseInterceptor wraps the raw payload:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* search pagination block */ }
}Search pagination lands on the top-level metadata key (not nested inside data). Pick metadata.total for "products matching this filter combo" and metadata.items for "hits in this page".
Error envelope
Standard HttpExceptionFilter shape:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ],
"debug": { /* dev-only */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 502 | BAD_GATEWAY (search backend unreachable) |
| 500 | INTERNAL_SERVER_ERROR |
Money & timestamps
- All money fields (
price,specialPrice,currentPrice,originalPrice,priceStart,priceEnd,minPrice,maxPrice) are integer minor currency units (paise/cents/eurocents). Never divide by 100. - Timestamps in API payloads are ISO 8601 strings.
Domain types
Used in payloads and responses below.
ProductSearchVariant
type ProductSearchVariant = {
id: string;
sku: string | null;
// Raw, indexed fields.
price: number | null; // integer minor unit
specialPrice: number | null;
specialPriceStartDate: string | null; // ISO
specialPriceEndDate: string | null;
inventoryQuantity: number; // quantityOnHand - reservedQuantity (>= 0)
minQuantityPerCart: number | null;
maxQuantityPerCart: number | null;
thumbnail: string | null;
images: string[];
// Computed at response time so values stay correct between
// special-price reroll cron ticks.
originalPrice: number | null; // = price
currentPrice: number | null; // = specialPriceActive ?? price
specialPriceActive: number | null; // null if no special is currently in effect
};ProductSearchProduct
type ProductSearchProduct = {
id: string;
title: string;
subtitle: string | null;
description: string | null;
slug: string; // we return `slug`, not `handle`
thumbnail: string | null;
images: string[];
priceStart: number | null; // min(currentPrice) across variants
priceEnd: number | null; // max(currentPrice) across variants
brand: { id: string; slug: string; name: string } | null;
inStock: boolean;
hasActiveSpecial: boolean;
variants: ProductSearchVariant[];
};ProductSearchBrandFacet / ProductSearchAttributeFacet
type ProductSearchBrandFacet = {
id: string;
slug: string;
name: string;
productCount: number;
};
type ProductSearchAttributeFacet = {
code: string; // productAttribute.code
title: string; // productAttribute.title (display)
values: Array<{ value: string; productCount: number }>;
};Attributes with zero matches in the result set are omitted from the response (keeps the payload tight).
ProductSearchMetadata
The pagination block surfaced on the top-level metadata envelope key.
type ProductSearchMetadata = {
total: number; // products matching the current filter combo (across all pages)
items: number; // hits returned in this page
perPage: number; // = limit
currentPage: number; // 1-indexed
lastPage: number; // ceil(total / perPage)
};SuggestionsPayload
type SuggestionsPayload = {
suggestions: string[]; // distinct titles + brand names
products: ProductSearchProduct[]; // top N matching products
};Storefront search
Base path: /store/product-search
GET / — Faceted product search
Free-text + faceted search across active, public, published products. Mirrors the response shape of beautybarn's product-search service so existing clients can swap in.
Query params
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free text (max 200 chars). Empty / missing → match-all |
brands | string? | — | CSV brand slugs — OR within the field |
categories | string? | — | CSV category slugs |
tag | string? | — | Single tag slug |
attributes | string? | — | JSON object: { "<code>": "value" | ["v1","v2"] }. AND across keys, OR within values. URL-encode the JSON |
minPrice | int? | — | integer minor unit, inclusive |
maxPrice | int? | — | integer minor unit, inclusive |
inStock | "true" | "false"? | — | Restrict to in-stock / out-of-stock |
hasActiveSpecial | "true" | "false"? | — | Restrict to "on sale" |
page | int | 1 | 1-indexed; max 1000 |
limit | int | 20 | Max 100 |
sortBy | enum? | (relevance) | See sort table below |
Sort options
sortBy | Effect |
|---|---|
relevance (default) | Typesense _text_match desc, then inStock desc |
price-asc | inStock desc, then priceMin asc — out-of-stock products sink |
price-desc | inStock desc, then priceMin desc |
new | publishedAtTs desc |
best-selling | Currently aliased to new until orders module ships |
inventory-high | totalInventory desc |
inventory-low | inStock desc, then totalInventory asc |
Example
curl -G https://api.example/store/product-search \
--data-urlencode 'q=lipstick' \
--data-urlencode 'brands=mac,nars' \
--data-urlencode 'categories=makeup' \
--data-urlencode 'attributes={"shade":["red","ruby"]}' \
--data-urlencode 'inStock=true' \
--data-urlencode 'sortBy=price-asc' \
--data-urlencode 'page=1' \
--data-urlencode 'limit=20'Response 200
{
"data": {
"products": [
{
"id": "01J9...",
"title": "Velvet Matte Lipstick",
"subtitle": "Long-wear",
"description": "...",
"slug": "velvet-matte-lipstick",
"thumbnail": "https://cdn.example/p/01J9....jpg",
"images": ["https://cdn.example/p/01J9....jpg"],
"priceStart": 129900,
"priceEnd": 149900,
"brand": { "id": "01J9...", "slug": "mac", "name": "MAC" },
"inStock": true,
"hasActiveSpecial": false,
"variants": [
{
"id": "01J9...",
"sku": "MAC-VML-RED-3G",
"price": 149900,
"specialPrice": null,
"specialPriceStartDate": null,
"specialPriceEndDate": null,
"inventoryQuantity": 12,
"minQuantityPerCart": null,
"maxQuantityPerCart": null,
"thumbnail": "https://cdn.example/v/01J9....jpg",
"images": [],
"originalPrice": 149900,
"currentPrice": 149900,
"specialPriceActive": null
}
]
}
],
"brands": [
{ "id": "01J9...", "slug": "mac", "name": "MAC", "productCount": 24 },
{ "id": "01J9...", "slug": "nars", "name": "NARS", "productCount": 11 }
],
"attributes": [
{
"code": "shade",
"title": "Shade",
"values": [
{ "value": "red", "productCount": 18 },
{ "value": "ruby", "productCount": 6 }
]
}
]
},
"message": "Success",
"statusCode": 200,
"metadata": {
"total": 35,
"items": 20,
"perPage": 20,
"currentPage": 1,
"lastPage": 2
}
}Beautybarn parity & deltas
Endpoint shape is intentionally close to beautybarn's /store/product-search so a client coded against that API drops in. Differences:
| Field | Status | Reason |
|---|---|---|
data.products[].slug | renamed (was handle) | Schema uses slug everywhere |
data.products[].variants[].salePrices, prices | omitted | No price-list module in supercommerce |
data.ingredients[] | omitted | No product↔ingredient link table |
data.ratingsCounts, minRating, sort top-rated | omitted | No reviews/ratings yet — re-add when reviews ship |
| pagination block | moved to top-level metadata (was meta inside the payload) | Matches the ResponseInterceptor envelope convention used everywhere else |
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad query string (e.g. malformed attributes JSON, limit > 100) |
| 502 | BAD_GATEWAY | Typesense unreachable |
GET /suggestions — Autocomplete
Prefix-tolerant suggestions for the search box. Returns distinct title + brand-name strings plus the top-N matching products.
Query params
| Name | Type | Default | Notes |
|---|---|---|---|
q | string | — | Required, 2–100 chars |
limit | int | 5 | Max 20 |
Response 200
{
"data": {
"suggestions": [
"Velvet Matte Lipstick",
"MAC",
"Velvet Matte Liner"
],
"products": [
{ "id": "01J9...", "title": "Velvet Matte Lipstick", /* ...full product shape... */ }
]
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | q shorter than 2 chars / over 100 |
| 502 | BAD_GATEWAY | Typesense unreachable |
Admin: bulk reindex
Base path: /admin/search
POST /reindex — Start a backfill run
Kicks off a chained, asynchronous bulk reindex. Returns 202 immediately with the run handle; actual work happens on the search BullMQ queue (each job paginates one batch, then re-enqueues itself with the next cursor).
Idempotent — re-runs over the same window simply re-upsert the same documents in Typesense. Safe to invoke any time, e.g. after a deploy that changes the projection logic, or per-vendor during a vendor onboarding.
Body
{
"vendorId": "01J9...", // optional — scope the run to one vendor
"batchSize": 500 // optional — default 500, max 2000
}| Field | Type | Constraints |
|---|---|---|
vendorId | string? | If set, only that vendor's products are reindexed |
batchSize | int? | 1 ≤ n ≤ 2000, default 500 |
Response 202
{
"data": {
"runId": "01J9...",
"jobId": "backfill-01J9...-start",
"vendorId": null,
"batchSize": 500
},
"message": "Success",
"statusCode": 202
}Track progress by tailing the API logs — SearchProcessor emits one backfill <runId>: indexed N skipped M failed K (cursor -> <id>) line per batch.
Permission required — search:reindex.
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | Missing / invalid bearer session |
| 403 | FORBIDDEN | Session lacks search:reindex |
| 400 | VALIDATION_ERROR | batchSize outside allowed range |
Indexing pipeline (internal)
The storefront index is an eventually-consistent cache of Postgres. Postgres is the source of truth — Typesense is rebuilt from it on every event.
ProductService writes ──► EventEmitter2 ──► ProductEventListener
│
▼
BullMQ "search" queue
(jobId-coalesced per product)
│
▼
SearchProcessor.indexOne
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
ProductProjectionService SEARCH_PORT.indexOne search_index_state
(Drizzle joins → doc) (Typesense upsert) (watermark row)Events the indexer subscribes to
From @sc/catalog:
| Event | Action |
|---|---|
VENDOR_PRODUCT_CREATED | enqueue reindex |
VENDOR_PRODUCT_BASICS_UPDATED | enqueue reindex |
VENDOR_PRODUCT_MEDIA_UPDATED | enqueue reindex |
VENDOR_PRODUCT_OPTIONS_REPLACED | enqueue reindex |
VENDOR_PRODUCT_SYNCED | enqueue reindex |
VENDOR_PRODUCT_VARIANT_CREATED | enqueue reindex |
VENDOR_PRODUCT_VARIANT_UPDATED | enqueue reindex |
VENDOR_PRODUCT_VARIANT_DELETED | enqueue reindex |
VENDOR_PRODUCT_VARIANTS_REORDERED | enqueue reindex |
VENDOR_PRODUCT_DELETED | enqueue deindex |
From @sc/inventory:
| Event | Action |
|---|---|
INVENTORY_STOCK_ADJUSTED | enqueue reindex only when delta is negative (debounced — restocks are picked up by the reconcile cron) |
Tab events (tab-*) are deliberately not subscribed — tab content is not part of the response contract today.
Job-ID coalescing
Every enqueue call uses jobId: \reindex-${productId}`(ordeindex-${productId}`). BullMQ rejects duplicate jobIds for jobs already in the queue, so a burst of events on one product (e.g. 5 media uploads in 200 ms) collapses to a single index pass. This keeps Typesense write throughput bounded under burst load.
Crons (registered in SearchProcessor.onModuleInit)
| Cron | Schedule | Purpose |
|---|---|---|
SEARCH_RECONCILE_CRON | */5 * * * * | Find products with updated_at > search_index_state.indexed_at (or no row at all) and re-enqueue. Backstop for lost jobs, manual DB writes, and the initial bootstrap (every existing product has no watermark row at first deploy) |
SEARCH_SPECIAL_REROLL_CRON | */10 * * * * | Re-enqueue products whose variant specialPriceStart/specialPriceEnd fell into the last 11-minute window. Keeps priceMin/priceMax/hasActiveSpecial near-real-time across special-price transitions |
Schema migration: search_index_state
CREATE TABLE "search_index_state" (
"product_id" text PRIMARY KEY REFERENCES "product"("id") ON DELETE CASCADE,
"indexed_at" timestamp NOT NULL,
"last_error" text,
"attempts" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
CREATE INDEX "search_index_state_indexed_at_idx" ON "search_index_state" ("indexed_at");(Generated via bun run db:generate — see api-modules/db/drizzle/0017_loose_krista_starr.sql.)
Side effects emitted
After each successful index / deindex, ProductIndexService re-emits a domain event:
| Event | Payload |
|---|---|
SEARCH_PRODUCT_INDEXED | { productId, vendorId, indexedAt } |
SEARCH_PRODUCT_DEINDEXED | { productId, vendorId | null } |
Other modules can subscribe — e.g. for analytics or cache invalidation.
Typesense collection
Logical alias products points at the active versioned physical collection (products_v1). The alias indirection lets us bump schema by indexing into products_v2 then atomically swapping the alias — zero downtime, no read pause.
Field overview
| Field | Type | Facet | Sort | Notes |
|---|---|---|---|---|
id, vendorId | string | vendorId yes | ||
title, slug, subtitle, description | string | title | Free-text searchable via query_by | |
thumbnail, images | string / string[] | index: false — stored only | ||
status, visibility | string | yes | Storefront pre-filter status:=active && visibility:=public && publishedAtTs:>0 | |
publishedAtTs, createdAtTs, updatedAtTs, indexedAtTs | int64 | yes | Unix ms (Typesense has no native date type) | |
brandId, brandSlug, brandName | string | yes | ||
categoryIds, categorySlugs, categoryNames | string[] | yes | Includes every assigned category, not just the primary | |
primaryCategoryId | string | |||
tagIds, tagSlugs, tagNames | string[] | yes | ||
attr_.* | string[] | yes | Wildcard. Each productAttribute.code becomes its own facet field at index time. Parallel attr_<code>_ids mirrors with attribute-value IDs | |
variants | object[] | index: false — embedded for response rendering | ||
priceMin, priceMax | int32 | yes | yes | Sentinel -1 means "no priced variant"; subunit pricing |
inStock, hasActiveSpecial | bool | yes | Pre-computed | |
totalInventory | int32 | yes | Sum of available inventory across variants |
Search request
The adapter constructs:
filter_by: status:=active && visibility:=public && publishedAtTs:>0
[ && brandSlug:=[`...`] ]
[ && categorySlugs:=[`...`] ]
[ && tagSlugs:=[`...`] ]
[ && attr_<code>:=[`...`] ... ]
[ && priceMin:>=N && priceMin:<=M ]
[ && inStock:=true|false ]
[ && hasActiveSpecial:=true|false ]
sort_by: inStock:desc,priceMin:asc (etc, depending on sortBy)
facet_by: brandSlug, categorySlugs, tagSlugs, inStock, hasActiveSpecial,
attr_<code> for every productAttribute (so filter chips paint
on the first request, not only after the user filters)
query_by: title, subtitle, description, brandName, categoryNames
num_typos: 2Provider abstraction (SEARCH_PORT)
ProductSearchService only depends on SEARCH_PORT, never on Typesense types. To swap providers (Algolia, Meilisearch, Elasticsearch, Postgres FTS, ...):
- Add
api-modules/search/src/adapters/<name>/<name>.adapter.tsimplementingSearchPort. - Add a branch in
pickAdapter()insidesearch.module.ts. - Switch
SearchModule.forRoot({ provider: "<name>", ... })inapps/api/src/app.module.ts.
The neutral interface (one file: api-modules/search/src/ports/search.port.ts):
export interface SearchPort {
ensureCollection(): Promise<void>;
dropCollection(): Promise<void>;
indexOne(doc: IndexableProduct): Promise<void>;
indexBulk(docs: IndexableProduct[]): Promise<BulkIndexResult>;
deindex(productId: string): Promise<void>;
search(query: SearchQuery): Promise<SearchResult>;
suggest(q: string, limit: number): Promise<SuggestionResult>;
}ensureCollection() runs once at boot via SearchBootstrapper.onModuleInit — idempotent, creates the collection / alias if missing.
Configuration
| Env var | Default | Purpose |
|---|---|---|
TYPESENSE_HOST | localhost | Typesense node host |
TYPESENSE_PORT | 8108 | Typesense node port |
TYPESENSE_PROTOCOL | http | http or https |
TYPESENSE_API_KEY | typesense_dev_key | Master / search key. The dev key is fine for local; production must override |
Other constants in api-modules/search/src/search.constants.ts (compile-time, not env):
| Constant | Value | Purpose |
|---|---|---|
DEFAULT_SEARCH_LIMIT | 20 | Storefront default page size |
MAX_SEARCH_LIMIT | 100 | Storefront max page size |
DEFAULT_SUGGESTIONS_LIMIT | 5 | Default suggestions cap |
DEFAULT_BACKFILL_BATCH_SIZE | 500 | Admin reindex default batch |
MAX_BACKFILL_BATCH_SIZE | 2000 | Admin reindex hard ceiling |
SEARCH_RECONCILE_CRON | */5 * * * * | Reconcile cadence |
SEARCH_SPECIAL_REROLL_CRON | */10 * * * * | Special-price reroll cadence |
SEARCH_SPECIAL_REROLL_WINDOW_MS | 11 * 60 * 1000 | Reroll lookback window |
Local development
# 1. Boot Typesense (and the rest of the stack)
docker compose up typesense postgres redis -d
# 2. Apply the search_index_state migration
bun run db:migrate
# 3. Start the API — SearchBootstrapper will create the products collection
bun --filter api start:dev
# 4. (Optional) Trigger an initial backfill instead of waiting for the
# 5-min reconcile cron to drip in batches of 100.
curl -X POST http://localhost:3000/admin/search/reindex \
-H "Authorization: Bearer $ADMIN_SESSION" \
-H "Content-Type: application/json" \
-d '{}'
# 5. Hit the storefront search.
curl 'http://localhost:3000/store/product-search?q=lipstick&page=1&limit=10'Tail the API logs to watch indexing progress; queue events emit one log line per job (reindex, deindex, backfill <runId>, reconcile, special-reroll).
Operational notes
- Typesense down → storefront search returns 502; indexing jobs retry with exponential backoff (5 attempts, 30 s base) and end up in the BullMQ DLQ on terminal failure. The reconcile cron re-discovers them on the next tick once Typesense is back.
- Schema change → bump
COLLECTION_PRODUCTS_VERSIONinsearch.constants.ts, redeploy.ensureCollectionwill createproducts_v2and switch the alias atomically. Then callPOST /admin/search/reindexto fill the new collection. - Attribute renamed in
productAttribute.code→ orphans the oldattr_<oldcode>field on existing docs. Trigger a backfill to clean up. - Inventory event storms during checkout peaks are absorbed by the negative-delta debounce in
InventoryEventListenerand the per-productId job coalescing — Typesense write rate stays bounded by the unique-product churn, not the total event rate.
Reviews Module
HTTP surface for product reviews — customer submission + anonymous read, vendor moderation of reviews on their own products (gated by admin settings), and admin full-CRUD with…
Settings Module
HTTP surface for platform settings (admin and store scopes managed by platform staff) and per-vendor settings (admin scope for vendor back-office config, store scope for…