A powerful search integration supporting Meilisearch and Algolia, providing full-text search, indexing, faceted search, and real-time updates for your application.
Installation
bunadd@stacksjs/search-engine
Basic Usage
import{useSearchEngine,useMeilisearch,useAlgolia}from'@stacksjs/search-engine'// Use configured search engineconstsearch=useSearchEngine()// Search documentsconstresults=awaitsearch.search('products','wireless headphones')// Add documents to indexawaitsearch.addDocuments('products',[{id:1,name:'Wireless Headphones',price:99.99}])
import{useMeilisearch}from'@stacksjs/search-engine'constmeilisearch=useMeilisearch()// Simple searchconstresults=awaitmeilisearch.search('products','headphones')// Search with optionsconstresults=awaitmeilisearch.search('products','headphones',{limit:20,offset:0,filter:'category = "electronics" AND price < 200',sort:['price:asc'],attributesToRetrieve:['id','name','price','description'],attributesToHighlight:['name','description'],attributesToCrop:['description'],cropLength:50})console.log(results.hits)// Search resultsconsole.log(results.query)// Original queryconsole.log(results.processingTimeMs)// Search timeconsole.log(results.estimatedTotalHits)// Total matches
import{useSearchEngine}from'@stacksjs/search-engine'// Index operations live on the driver, not as free functions - the driver is// resolved from `config/search-engine.ts` so the same code works on// Meilisearch, Algolia, OpenSearch or Typesense.constsearch=useSearchEngine()// Add documentsawaitsearch.addDocuments('products',[{id:1,name:'Wireless Mouse',description:'Ergonomic wireless mouse with long battery life',category:'electronics',brand:'Logitech',price:49.99,inStock:true,rating:4.5},{id:2,name:'Mechanical Keyboard',description:'RGB mechanical keyboard with cherry switches',category:'electronics',brand:'Corsair',price:129.99,inStock:true,rating:4.8}])// Update documents (merges with existing)awaitsearch.updateDocuments('products',[{id:1,price:44.99}// Only updates price])// Delete documentsawaitsearch.deleteDocuments('products',[1,2])// Delete all documentsawaitflushDocuments('products')
Index Management
import{useSearchEngine}from'@stacksjs/search-engine'constsearch=useSearchEngine()// List all indexesconstindexes=awaitsearch.listAllIndexes()// Create index with primary keyawaitsearch.createIndex('products',{primaryKey:'id'})// Delete indexawaitsearch.deleteIndex('products')
Index Settings
import{useSearchEngine}from'@stacksjs/search-engine'constsearch=useSearchEngine()// Get current settingsconstsettings=awaitsearch.getSettings('products')// Update settingsawaitsearch.updateSettings('products',{// Searchable attributes (in priority order)searchableAttributes:['name','description','category','brand'],// Filterable attributes (for filtering and facets)filterableAttributes:['category','brand','price','inStock','rating'],// Sortable attributessortableAttributes:['price','rating','createdAt'],// Ranking rules (order matters)rankingRules:['words','typo','proximity','attribute','sort','exactness','rating:desc'// Custom ranking by rating],// Stop words (ignored in search)stopWords:['the','a','an','is','are'],// Synonymssynonyms:{'phone':['smartphone','mobile','cell'],'laptop':['notebook','computer']},// Displayed attributes (returned in results)displayedAttributes:['*'],// All attributes// Distinct attribute (for deduplication)distinctAttribute:'productGroup',// Typo tolerancetypoTolerance:{enabled:true,minWordSizeForTypos:{oneTypo:5,twoTypos:9},disableOnWords:['exact-match'],disableOnAttributes:['sku']},// Pagination limitspagination:{maxTotalHits:10000}})
Algolia
Basic Search
import{useAlgolia}from'@stacksjs/search-engine'constalgolia=useAlgolia()// Simple searchconstresults=awaitalgolia.search('products','headphones')// Search with parametersconstresults=awaitalgolia.search('products','headphones',{hitsPerPage:20,page:0,filters:'category:electronics AND price < 200',facets:['category','brand'],attributesToRetrieve:['name','price','description'],attributesToHighlight:['name','description'],highlightPreTag:'<mark>',highlightPostTag:'</mark>'})
Multi-Index Search
// Search across multiple indexesconstresults=awaitalgolia.multiSearch([{indexName:'products',query:'laptop'},{indexName:'categories',query:'laptop'},{indexName:'brands',query:'laptop'}])
// app/Models/Product.tsexportdefault{name:'Product',table:'products',searchable:true,searchIndex:'products',// Attributes to indexsearchableAttributes:['name','description','category','brand','sku'],// Transform data for searchtoSearchableArray(){return{id:this.id,name:this.name,description:this.description,category:this.category?.name,brand:this.brand?.name,price:this.price,inStock:this.inventory>0,rating:this.averageRating,createdAt:this.createdAt.getTime()}}}
Auto-Sync
// Models automatically sync on create/update/deleteconstproduct=awaitProduct.create({name:'New Product',price:99.99})// Automatically indexed in search engineawaitproduct.update({price:89.99})// Automatically updated in search indexawaitproduct.delete()// Automatically removed from search index
Manual Sync
// Sync single modelawaitproduct.searchable()// Remove from searchawaitproduct.unsearchable()// Bulk syncawaitProduct.where('category','electronics').searchable()// Rebuild entire indexawaitProduct.reindex()
// Search with typosconstresults=awaitsearch.search('products','wireles headphnes')// Still matches "wireless headphones"
Geo Search
// Search near a locationconstresults=awaitsearch.search('stores','',{filter:'_geoRadius(48.8566, 2.3522, 1000)',// 1km radius around Parissort:['_geoPoint(48.8566, 2.3522):asc']})
Filtering
// Complex filtersconstresults=awaitsearch.search('products','laptop',{filter:['category = "electronics"','brand IN ["Apple", "Dell", "HP"]','price >= 500','price <= 2000','inStock = true','rating >= 4'].join(' AND ')})
Sorting
// Sort by multiple attributesconstresults=awaitsearch.search('products','laptop',{sort:['featured:desc','price:asc','rating:desc']})
Keeping the index in step
Indexing is driven from the model rather than called by hand. A model with the
useSearch trait writes to the index on create, update and delete, so there is
nothing to remember at each call site:
// app/Models/Product.tsexportdefaultdefineModel({name:'Product',traits:{useSearch:{searchable:['name','description'],filterable:['status'],// Dispatch to the queue instead of writing inline. Worth turning on// whenever the search backend is on another host: a slow upsert or a// transient outage then costs a retry rather than the user's write.queueable:true,},},// ...})
With queueable: true the model's hooks dispatch SyncSearchIndexJob, which
retries with backoff. Without it the write happens inline, which is fine for a
local Meilisearch and not for a managed one across a network.
To reindex on demand - after changing searchable, or to backfill - go through
the driver:
// Batch indexing for large datasetsconstproducts=awaitProduct.all()constbatches=chunk(products,500)for(constbatchofbatches){awaitsearch.addDocuments('products',batch.map(p=>p.toSearchableArray()))// Optional: Add delay to avoid rate limitingawaitsleep(100)}
Handling Connection Failures
try{constresults=awaitsearch.search('products','query')}catch(error){if(error.code==='ECONNREFUSED'){// Search engine unavailable, fallback to databaseconstresults=awaitProduct.whereLike('name',`%query%`).get()returnresults}throwerror}
Index Versioning
// Create new index versionconstnewIndex='products_v2'awaitsearch.createIndex(newIndex)// Populate new indexawaitsearch.addDocuments(newIndex,documents)// Swap indexes atomicallyawaitswapIndexes('products',newIndex)
Empty Results Handling
constresults=awaitsearch.search('products','nonexistent')if(results.hits.length===0){// Show suggestionsconstsuggestions=awaitsearch.search('products','',{limit:5,sort:['rating:desc']})return{hits:[],suggestions:suggestions.hits}}