Supercommerce API Docs
Full Module Docs

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 via SearchModule.forRoot({ provider: "typesense", ... }) in apps/api/src/app.module.ts).


Conventions

Authentication

SurfaceAuth
GET /store/product-searchUnauthenticated (public storefront)
GET /store/product-search/suggestionsUnauthenticated
POST /admin/search/reindexBetter-Auth bearer session + search:reindex permission
Authorization: Bearer <session-token>

The admin endpoint is protected by BetterAuthGuard + PermissionsGuard. The search resource exposes the actions:

ResourceActions
searchreindex

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 */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
502BAD_GATEWAY (search backend unreachable)
500INTERNAL_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
};

Base path: /store/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

NameTypeDefaultNotes
qstring?Free text (max 200 chars). Empty / missing → match-all
brandsstring?CSV brand slugs — OR within the field
categoriesstring?CSV category slugs
tagstring?Single tag slug
attributesstring?JSON object: { "<code>": "value" | ["v1","v2"] }. AND across keys, OR within values. URL-encode the JSON
minPriceint?integer minor unit, inclusive
maxPriceint?integer minor unit, inclusive
inStock"true" | "false"?Restrict to in-stock / out-of-stock
hasActiveSpecial"true" | "false"?Restrict to "on sale"
pageint11-indexed; max 1000
limitint20Max 100
sortByenum?(relevance)See sort table below

Sort options

sortByEffect
relevance (default)Typesense _text_match desc, then inStock desc
price-ascinStock desc, then priceMin asc — out-of-stock products sink
price-descinStock desc, then priceMin desc
newpublishedAtTs desc
best-sellingCurrently aliased to new until orders module ships
inventory-hightotalInventory desc
inventory-lowinStock 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:

FieldStatusReason
data.products[].slugrenamed (was handle)Schema uses slug everywhere
data.products[].variants[].salePrices, pricesomittedNo price-list module in supercommerce
data.ingredients[]omittedNo product↔ingredient link table
data.ratingsCounts, minRating, sort top-ratedomittedNo reviews/ratings yet — re-add when reviews ship
pagination blockmoved to top-level metadata (was meta inside the payload)Matches the ResponseInterceptor envelope convention used everywhere else

Errors

StatusCodeWhen
400VALIDATION_ERRORBad query string (e.g. malformed attributes JSON, limit > 100)
502BAD_GATEWAYTypesense 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

NameTypeDefaultNotes
qstringRequired, 2–100 chars
limitint5Max 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

StatusCodeWhen
400VALIDATION_ERRORq shorter than 2 chars / over 100
502BAD_GATEWAYTypesense 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
}
FieldTypeConstraints
vendorIdstring?If set, only that vendor's products are reindexed
batchSizeint?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 requiredsearch:reindex.

Errors

StatusCodeWhen
401UNAUTHORIZEDMissing / invalid bearer session
403FORBIDDENSession lacks search:reindex
400VALIDATION_ERRORbatchSize 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:

EventAction
VENDOR_PRODUCT_CREATEDenqueue reindex
VENDOR_PRODUCT_BASICS_UPDATEDenqueue reindex
VENDOR_PRODUCT_MEDIA_UPDATEDenqueue reindex
VENDOR_PRODUCT_OPTIONS_REPLACEDenqueue reindex
VENDOR_PRODUCT_SYNCEDenqueue reindex
VENDOR_PRODUCT_VARIANT_CREATEDenqueue reindex
VENDOR_PRODUCT_VARIANT_UPDATEDenqueue reindex
VENDOR_PRODUCT_VARIANT_DELETEDenqueue reindex
VENDOR_PRODUCT_VARIANTS_REORDEREDenqueue reindex
VENDOR_PRODUCT_DELETEDenqueue deindex

From @sc/inventory:

EventAction
INVENTORY_STOCK_ADJUSTEDenqueue 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)

CronSchedulePurpose
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:

EventPayload
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

FieldTypeFacetSortNotes
id, vendorIdstringvendorId yes
title, slug, subtitle, descriptionstringtitleFree-text searchable via query_by
thumbnail, imagesstring / string[]index: false — stored only
status, visibilitystringyesStorefront pre-filter status:=active && visibility:=public && publishedAtTs:>0
publishedAtTs, createdAtTs, updatedAtTs, indexedAtTsint64yesUnix ms (Typesense has no native date type)
brandId, brandSlug, brandNamestringyes
categoryIds, categorySlugs, categoryNamesstring[]yesIncludes every assigned category, not just the primary
primaryCategoryIdstring
tagIds, tagSlugs, tagNamesstring[]yes
attr_.*string[]yesWildcard. Each productAttribute.code becomes its own facet field at index time. Parallel attr_<code>_ids mirrors with attribute-value IDs
variantsobject[]index: false — embedded for response rendering
priceMin, priceMaxint32yesyesSentinel -1 means "no priced variant"; subunit pricing
inStock, hasActiveSpecialboolyesPre-computed
totalInventoryint32yesSum 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:  2

Provider abstraction (SEARCH_PORT)

ProductSearchService only depends on SEARCH_PORT, never on Typesense types. To swap providers (Algolia, Meilisearch, Elasticsearch, Postgres FTS, ...):

  1. Add api-modules/search/src/adapters/<name>/<name>.adapter.ts implementing SearchPort.
  2. Add a branch in pickAdapter() inside search.module.ts.
  3. Switch SearchModule.forRoot({ provider: "<name>", ... }) in apps/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 varDefaultPurpose
TYPESENSE_HOSTlocalhostTypesense node host
TYPESENSE_PORT8108Typesense node port
TYPESENSE_PROTOCOLhttphttp or https
TYPESENSE_API_KEYtypesense_dev_keyMaster / 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):

ConstantValuePurpose
DEFAULT_SEARCH_LIMIT20Storefront default page size
MAX_SEARCH_LIMIT100Storefront max page size
DEFAULT_SUGGESTIONS_LIMIT5Default suggestions cap
DEFAULT_BACKFILL_BATCH_SIZE500Admin reindex default batch
MAX_BACKFILL_BATCH_SIZE2000Admin reindex hard ceiling
SEARCH_RECONCILE_CRON*/5 * * * *Reconcile cadence
SEARCH_SPECIAL_REROLL_CRON*/10 * * * *Special-price reroll cadence
SEARCH_SPECIAL_REROLL_WINDOW_MS11 * 60 * 1000Reroll 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_VERSION in search.constants.ts, redeploy. ensureCollection will create products_v2 and switch the alias atomically. Then call POST /admin/search/reindex to fill the new collection.
  • Attribute renamed in productAttribute.code → orphans the old attr_<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 InventoryEventListener and the per-productId job coalescing — Typesense write rate stays bounded by the unique-product churn, not the total event rate.

On this page