Context
We previously implemented Meilisearch as a self-hosted Algolia replacement (see meilisearch branch). The implementation worked — search results returned correctly — but the filter/facet UI was broken because Meilisearch's facetDistribution only returns counts for the unfiltered universe. Once a filter is applied, counts for other values of that same attribute disappear, making the filter drawer unusable.
Typesense is the correct replacement because:
- It returns facet counts for all values regardless of active filters — correct behavior for a filter drawer
- It has an Algolia-compatible API adapter (
typesense-instantsearch-adapter) for future use
- Single Docker binary, same operational simplicity as Meilisearch
- The existing
SearchState interface and CandidateSearchService are fully reusable — only the HTTP layer changes
This issue covers backend and infrastructure only. No frontend changes.
Scope
1. docker-compose.yml
Add Typesense service:
typesense:
image: typesense/typesense:27.1
restart: unless-stopped
ports:
- "8108:8108"
volumes:
- ./typesense-data:/data
command: --data-dir /data --api-key=${TYPESENSE_API_KEY} --enable-cors
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
Add to .env.example:
TYPESENSE_API_KEY=your-admin-key-here
TYPESENSE_HOST=typesense
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
2. Backend: /search/key endpoint
Replace /meilisearch/key with /search/key.
Returns a scoped search-only API key (never the admin key):
{
"apiKey": "<scoped-search-key>",
"apiKeyValidUntil": 1234567890,
"host": "typesense.yourdomain.com",
"port": 443,
"protocol": "https"
}
Generate scoped key server-side using Typesense SDK:
const client = new Typesense.Client({ ... });
const scopedKey = client.keys().generateScopedSearchKey(
process.env.TYPESENSE_API_KEY,
{ filter_by: '', expires_at: Math.floor(Date.now()/1000) + 3600 }
);
3. Backend: /search/search endpoint
Replace /meilisearch/search with /search/search.
Accepts the same request body shape that CandidateSearchService already sends:
{
collectionName: string; // was: indexName
query?: string;
filters?: Record<string, string[]>;
sort?: string[];
page?: number;
hitsPerPage?: number;
facets?: string[];
}
Filter syntax translation (backend responsibility):
// Input from frontend: { gender: ['Male'], university_id: ['1', '2'] }
// Output to Typesense:
const filterBy = Object.entries(filters)
.map(([attr, vals]) => `${attr}:=[${vals.join(',')}]`)
.join(' && ');
// Result: "gender:=[Male] && university_id:=[1,2]"
Returns normalized response:
{
hits: any[];
pagination: { total, page, hitsPerPage, totalPages };
facets: Record<string, Record<string, number>>; // always populated regardless of active filters
processingTimeMs: number;
query: string;
}
4. Index schema for candidates
Define Typesense collection schema for candidates index.
Key fields to mark as facet: true:
candidate_gender
candidate_driving_license
university.university_id
bank.bank_id
have_video, have_resume, candidate_committed, assigned
candidate_mom_kuwaiti, isProfileCompleted
currency_code, country.country_id, store.store_id
5. Index sync script
Create a one-time migration script scripts/sync-to-typesense.ts that reads from the existing data source and bulk-imports into Typesense.
Acceptance Criteria
Branch
Branch from master. Name: feature/typesense-infra
Related
Context
We previously implemented Meilisearch as a self-hosted Algolia replacement (see
meilisearchbranch). The implementation worked — search results returned correctly — but the filter/facet UI was broken because Meilisearch'sfacetDistributiononly returns counts for the unfiltered universe. Once a filter is applied, counts for other values of that same attribute disappear, making the filter drawer unusable.Typesense is the correct replacement because:
typesense-instantsearch-adapter) for future useSearchStateinterface andCandidateSearchServiceare fully reusable — only the HTTP layer changesThis issue covers backend and infrastructure only. No frontend changes.
Scope
1. docker-compose.yml
Add Typesense service:
Add to
.env.example:2. Backend:
/search/keyendpointReplace
/meilisearch/keywith/search/key.Returns a scoped search-only API key (never the admin key):
{ "apiKey": "<scoped-search-key>", "apiKeyValidUntil": 1234567890, "host": "typesense.yourdomain.com", "port": 443, "protocol": "https" }Generate scoped key server-side using Typesense SDK:
3. Backend:
/search/searchendpointReplace
/meilisearch/searchwith/search/search.Accepts the same request body shape that
CandidateSearchServicealready sends:Filter syntax translation (backend responsibility):
Returns normalized response:
4. Index schema for candidates
Define Typesense collection schema for candidates index.
Key fields to mark as
facet: true:candidate_gendercandidate_driving_licenseuniversity.university_idbank.bank_idhave_video,have_resume,candidate_committed,assignedcandidate_mom_kuwaiti,isProfileCompletedcurrency_code,country.country_id,store.store_id5. Index sync script
Create a one-time migration script
scripts/sync-to-typesense.tsthat reads from the existing data source and bulk-imports into Typesense.Acceptance Criteria
docker-compose upstarts Typesense alongside the appGET /search/keyreturns a valid scoped search keyPOST /search/searchwith a query returns results in normalized formatBranch
Branch from
master. Name:feature/typesense-infraRelated