Developer Docs

Universal Search Index โ€” Developer Guide

Overview

The Universal Search Index extends Mallnline's Lobby discovery engine beyond commerce items (Products and Services) to include Blog Posts, Malets themselves, and Platform Guides as first-class searchable entities. This transforms the search experience from a product catalog into a comprehensive platform-wide discovery system.

Concept Description
ItemType Polymorphic discriminator for indexed content: PRODUCT, SERVICE, BLOG_POST, MALET, GUIDE
ContentSource Origin discriminator: USER_GENERATED (marketplace content) vs PLATFORM (guides, help articles)
Event-Driven Sync TCP microservice events flow from source subgraphs to the search index in real-time
Status-Aware Indexing Only content in an "active" state is indexed โ€” unpublished blog posts and guides are automatically removed

Architecture

graph TD
    subgraph Sources
        P["Products Subgraph"]
        S["Services Subgraph"]
        B["Blogs Subgraph"]
        M["Malets Subgraph"]
        G["Guide Module (Search)"]
    end

    subgraph Search Subgraph
        SC["SearchController (TCP)"]
        GS["GuideService"]
        MS["MeilisearchService"]
        BI["BulkImportService"]
        RC["ReconciliationService"]
    end

    subgraph Index
        MI["Meilisearch Index"]
    end

    P -- "item_upserted / item_deleted" --> SC
    S -- "item_upserted / item_deleted" --> SC
    B -- "blog_post_upserted / blog_post_deleted" --> SC
    M -- "malet_upserted / malet_tags_updated" --> SC

    SC --> MS --> MI
    GS --> MS
    BI --> MS
    RC --> MS

    style MI fill:#f59e0b,color:#fff
    style SC fill:#3b82f6,color:#fff
    style GS fill:#10b981,color:#fff

Data Flow

  1. Source subgraphs emit TCP events when content changes (create, update, status transition, delete)
  2. SearchController receives events, enriches with Malet metadata (name, verification, section tags), and indexes via MeilisearchService
  3. GuideService manages platform guides internally within the search subgraph โ€” no external TCP events needed
  4. BulkImportService and ReconciliationService cover all five entity types for administrative integrity

ItemType Enum

The ItemType enum determines how each indexed document is categorized and filtered in the Lobby:

enum ItemType {
	PRODUCT = 'PRODUCT', // Physical or digital goods from a Malet
	SERVICE = 'SERVICE', // Bookable services from a Malet
	BLOG_POST = 'BLOG_POST', // Published blog posts from a Malet
	MALET = 'MALET', // The Malet itself (Mall Outlet)
	GUIDE = 'GUIDE' // Platform-managed user guides and help articles
}

Filtering by Type

Visitors can filter search results by type using the types array:

query {
	search(query: "photography", filters: { types: [MALET, BLOG_POST] }) {
		results {
			id
			name
			type
			slug
			contentSource
		}
		facets {
			types {
				type
				count
			}
		}
		totalCount
	}
}

The types array uses OR logic โ€” results matching any of the specified types are returned. If omitted, all types are searched.

IMPORTANT

Frontend callers should prefer types: [...] (array) over type: ... (singular) when filtering by item type. Both fields are supported at the GraphQL schema level, but types is the canonical field and avoids edge-case ambiguity with the type field name in federated contexts.


ContentSource Discriminator

The ContentSource enum separates user-generated marketplace content from platform-managed documentation:

enum ContentSource {
	USER_GENERATED = 'USER_GENERATED', // Products, Services, Blog Posts, Malets
	PLATFORM = 'PLATFORM' // Guides, help articles, manuals
}

Filtering by Content Source

query {
	search(query: "getting started", filters: { contentSource: PLATFORM }) {
		results {
			id
			name
			type
			slug
		}
		totalCount
	}
}

This is useful for building separate search experiences โ€” e.g., a help center that only searches platform guides, or a Lobby that only shows marketplace content.


Entity Indexing Details

Products & Services (USER_GENERATED)

Commerce items are indexed with full pricing, category, Malet enrichment, and availability:

Index Field Source
name Product/Service name
description Product/Service description
price { amount, currency } from basePrice
maletId / maletName Owning Malet
organizationId Malet Owner's Organization
categories Category IDs
sectionTags From owning Malet's Tag Registry
isAvailable status === 'ACTIVE'
averageRating / reviewCount Aggregated from community reviews

Blog Posts (USER_GENERATED)

Blog posts are indexed only when their status is PUBLISHED. Status transitions to DRAFT, ARCHIVED, or SCHEDULED trigger automatic removal from the index.

Index Field Source
name Blog post title
description Excerpt
slug URL-friendly slug for deep linking
categories Blog post tags
maletId / maletName Owning Malet
sectionTags From owning Malet
price null (non-commerce)
isAvailable Always true when indexed

TCP Events:

// Emitted from BlogQueryService on create/update
'blog_post_upserted'  โ†’ { id: string }  // Index/re-index if PUBLISHED
'blog_post_deleted'   โ†’ { id: string }  // Remove from index

Malets (USER_GENERATED)

Malets are indexed as standalone entities so Visitors can discover Malets directly by name, description, or handle:

Index Field Source
name Malet name
description Malet description
slug Malet handle (e.g., luminara-crafts)
sectionTags Malet's own section tags
maletVerificationStatus Verification status
price null (non-commerce)
maletId null (the Malet is the entity)

TCP Event:

'malet_upserted' โ†’ { id: string }  // Index/re-index Malet

Guides (PLATFORM)

Platform guides are managed entirely within the search subgraph via the GuideService. No external TCP events are needed โ€” indexing happens automatically on CRUD operations.

Index Field Source
name Guide title
description Excerpt or first 300 chars of body
slug Unique URL slug
categories Guide tags
contentSource Always PLATFORM
price null
maletId null

Guide Module

The Guide Module is a self-contained CRUD system inside the search subgraph for managing platform documentation โ€” user guides, help articles, and manuals.

GuideCategory Enum

enum GuideCategory {
	GETTING_STARTED = 'GETTING_STARTED',
	SELLER_GUIDE = 'SELLER_GUIDE',
	BUYER_GUIDE = 'BUYER_GUIDE',
	API_REFERENCE = 'API_REFERENCE',
	TROUBLESHOOTING = 'TROUBLESHOOTING',
	BEST_PRACTICES = 'BEST_PRACTICES',
	PLATFORM_UPDATES = 'PLATFORM_UPDATES'
}

GraphQL API

Queries

# Public: published guides sorted by category + order
query {
	guides {
		id
		title
		slug
		category
		isPublished
		sortOrder
	}
}

# Admin: all guides including unpublished
query {
	allGuides {
		id
		title
		slug
		category
		isPublished
	}
}

# Get by slug
query {
	guideBySlug(slug: "getting-started-with-malets") {
		id
		title
		body
		excerpt
		category
		tags
	}
}

Mutations

# Create
mutation {
	createGuide(
		input: {
			title: "Getting Started with Malets"
			body: "# Welcome\n\nThis guide covers..."
			category: GETTING_STARTED
			slug: "getting-started-with-malets"
			tags: ["onboarding", "setup"]
		}
	) {
		id
		slug
	}
}

# Update
mutation {
	updateGuide(
		id: "guide-id"
		input: {
			title: "Updated Title"
			isPublished: false # Removes from search index
		}
	) {
		id
		title
		isPublished
	}
}

# Delete
mutation {
	deleteGuide(id: "guide-id")
}

NOTE

Setting isPublished: false automatically removes the guide from the Meilisearch index. Re-publishing re-indexes it with contentSource: PLATFORM.


Cross-Subgraph Event Sync

The blogs subgraph emits TCP events to the SEARCH_SERVICE client whenever blog post lifecycle changes occur:

sequenceDiagram
    participant BO as Malet Owner
    participant BQ as BlogQueryService
    participant SC as SearchController
    participant MS as MeilisearchService

    BO->>BQ: createOne(blog, status: PUBLISHED)
    BQ->>SC: emit('blog_post_upserted', { id })
    SC->>SC: Fetch blog + Malet metadata
    SC->>MS: indexItem(enriched document)

    BO->>BQ: updateOne(blog, status: DRAFT)
    BQ->>SC: emit('blog_post_deleted', { id })
    SC->>MS: deleteItem(id)

Key behaviors:

  • Only PUBLISHED blog posts are indexed
  • Transitioning away from PUBLISHED (โ†’ DRAFT, ARCHIVED, SCHEDULED) triggers deletion from the index
  • The SEARCH_SERVICE TCP client is registered inside the NestjsQueryGraphQLModule.forFeature() child context, matching the DI scope of BlogQueryService

Malet events flow from the malets subgraph:

  • malet_upserted โ€” Indexes/re-indexes the Malet as a standalone entity
  • malet_tags_updated โ€” Batch-updates sectionTags on all indexed items belonging to the Malet
  • malet_verification_updated โ€” Updates maletVerificationStatus across all Malet items

SearchFiltersInput

The full SearchFiltersInput type supports filtering across all entity types:

input SearchFiltersInput {
	type: ItemType # Single type filter
	types: [ItemType!] # Multi-type filter (OR logic, overrides type)
	contentSource: ContentSource # PLATFORM or USER_GENERATED
	maletId: String # Filter by owning Malet
	organizationId: String # Filter by Organization
	categories: [String!] # Filter by categories (OR logic)
	sectionTags: [String!] # Filter by section tags (OR logic)
	priceRange: PriceRangeInput # Min/max price
	dateRange: DateRangeInput # After/before dates
	isAvailable: Boolean # Availability status
	maletVerificationStatus: String # Malet verification filter
	location: GeoFilterInput # Geo-radius search
}

IMPORTANT

All fields in SearchFiltersInput must carry a @IsOptional() decorator from class-validator. The search service runs ValidationPipe({ whitelist: true }) in main.ts. Without @IsOptional(), the whitelist pipe treats every filter field as "undecorated" and silently strips it โ€” causing filters to arrive as {}. If you add a new filter field, always include @IsOptional() above the @Field() decorator.


Bulk Import & Reconciliation

Both administrative services have been updated to cover all five entity types:

Bulk Import

# Import all entity types
mutation {
	bulkImportAll {
		jobId
		status
	}
}

# Import by specific type
mutation {
	bulkImportByType(input: { type: BLOG_POST }) {
		jobId
		status
	}
}

Supported types: PRODUCT, SERVICE, BLOG_POST, MALET, GUIDE

Reconciliation

The daily 3 AM cron job detects drift across all entity types:

  • Missing items โ†’ re-indexed from source database
  • Orphaned items โ†’ removed from Meilisearch
  • Stale items โ†’ re-indexed with fresh data

For guides, the GuideService.reindexAll() method is available for manual re-indexing.


Module Structure

apps/search/src/
โ”œโ”€โ”€ index/
โ”‚   โ”œโ”€โ”€ item-index.model.ts         # ItemIndex, ItemType, ContentSource
โ”‚   โ””โ”€โ”€ stubs.ts                    # ProductStub, ServiceStub, MaletStub,
โ”‚                                   # ReviewStub, BlogPostStub
โ”œโ”€โ”€ guides/
โ”‚   โ”œโ”€โ”€ guide.entity.ts             # Guide entity, GuideCategory, DTOs
โ”‚   โ”œโ”€โ”€ guide.service.ts            # CRUD + Meilisearch indexing
โ”‚   โ”œโ”€โ”€ guide.service.spec.ts       # 11 unit tests
โ”‚   โ”œโ”€โ”€ guide.resolver.ts           # GraphQL queries + mutations
โ”‚   โ””โ”€โ”€ guide.module.ts             # NestJS module
โ”œโ”€โ”€ meilisearch/
โ”‚   โ””โ”€โ”€ meilisearch.service.ts      # filterableAttributes, primaryKey: 'id'
โ”œโ”€โ”€ bulk-import/
โ”‚   โ””โ”€โ”€ bulk-import.service.ts      # Covers all 5 entity types
โ”œโ”€โ”€ reconciliation/
โ”‚   โ””โ”€โ”€ reconciliation.service.ts   # Drift detection for all 5 types
โ””โ”€โ”€ search.controller.ts            # TCP event handlers

apps/blogs/src/blog/
โ”œโ”€โ”€ blog.query-service.ts           # Emits blog_post_upserted/deleted
โ””โ”€โ”€ blog.module.ts                  # SEARCH_SERVICE client registration

Environment Variables

The blogs subgraph requires these variables for search event emission:

# Search service TCP connection (blogs subgraph)
SEARCH_SERVICE_HOST=127.0.0.1
SEARCH_SERVICE_PORT_TCP=3021

These are already configured in the platform .env file and used by other subgraphs (e.g., malets, products).


Testing

Search Subgraph

# All search tests (133 tests across 14 suites)
npm run test -- apps/search --no-coverage

Key coverage:

  • SearchController: Blog post, Malet, and guide event handlers
  • GuideService: CRUD, Meilisearch indexing, reindexAll
  • BulkImportService: All 5 entity types with contentSource tagging
  • ReconciliationService: Drift detection across all types

Blogs Subgraph

# All blogs tests (63 tests across 9 suites)
npm run test -- apps/blogs --no-coverage

Key coverage:

  • BlogQueryService: TCP event emission on create/update, status-aware indexing

Gotchas & Troubleshooting

Primary Key Auto-Detection Failure

Meilisearch auto-detects the primary key from document fields ending in id. Because ItemIndex contains both id and maletId, auto-detection fails with error index_primary_key_multiple_candidates_found, silently rejecting every batch.

Prevention: MeilisearchService.onModuleInit() explicitly calls createIndex('items', { primaryKey: 'id' }) before any indexing, and all addDocuments() calls pass { primaryKey: 'id' } as a defensive measure.

Symptoms: bulkImportAll returns success: true, indexed: 119 but GET /indexes/items/stats shows numberOfDocuments: 0. Check GET /tasks?limit=5 for status: "failed" entries with the error code above.

Recovery:

# Delete and recreate the index with the correct primaryKey
curl -X DELETE -H "Authorization: Bearer $MEILI_KEY" $MEILI_HOST/indexes/items
curl -X POST -H "Authorization: Bearer $MEILI_KEY" -H "Content-Type: application/json" \
  $MEILI_HOST/indexes -d '{"uid":"items","primaryKey":"id"}'
# Wait a few seconds, then re-run bulk import

ValidationPipe Stripping Filter Fields

The search service uses ValidationPipe({ whitelist: true, transform: true }) in main.ts. The whitelist: true option strips any properties from input DTOs that lack class-validator decorators. If you add a new field to SearchFiltersInput without @IsOptional(), it will be silently dropped and the filter will not apply.

Symptoms: Search results ignore the filter entirely โ€” totalCount returns the same value whether the filter is set or not.

Fix: Always add @IsOptional() (from class-validator) above the @Field() decorator on every nullable field in SearchFiltersInput.

Meilisearch Tasks Are Asynchronous

All document write operations (addDocuments, deleteDocuments, updateSettings) are enqueued by Meilisearch and processed asynchronously. The Node.js SDK resolves immediately when the task is enqueued, not when it completes. This means a bulkImportAll mutation can return success: true while the actual indexing task fails minutes later.

Debugging: Use GET /tasks?limit=10 against the Meilisearch REST API to inspect task statuses, error messages, and processing duration.