Full-Text Search (Meilisearch)
Svelar provides a Searchable mixin that integrates your ORM models with Meilisearch for fast, typo-tolerant full-text search. Indexes stay in sync automatically as you create, update, and delete records.
Installation
npm install meilisearch
You need a running Meilisearch instance. For Docker setups, use:
npx svelar make:docker --meilisearch
Or run Meilisearch standalone:
docker run -d -p 7700:7700 -v meili_data:/meili_data \
-e MEILI_MASTER_KEY=your-master-key \
getmeili/meilisearch:v1.13
Configuration
Configure the search client in your src/app.ts:
import { Search } from '@beeblock/svelar/search';
Search.configure({
host: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_KEY,
indexPrefix: 'myapp', // optional — prefixes all index names
});
Add these to your .env:
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_KEY=your-master-key
Making Models Searchable
Add the Searchable mixin to any model:
import { Model } from '@beeblock/svelar/orm';
import { Searchable } from '@beeblock/svelar/search';
class Post extends Searchable(Model) {
static table = 'posts';
}
That's it. Every time a Post is created, updated, or deleted, the search index updates automatically.
With HasRoles
Mixins compose — stack them:
import { HasRoles } from '@beeblock/svelar/permissions';
class User extends Searchable(HasRoles(Model)) {
static table = 'users';
}
Customizing Indexed Data
By default, all model attributes are indexed. Override toSearchableObject() to control what gets sent to Meilisearch:
class Post extends Searchable(Model) {
static table = 'posts';
toSearchableObject() {
return {
id: this.getAttribute('id'),
title: this.getAttribute('title'),
content: this.getAttribute('content'),
author_name: this.getAttribute('author_name'),
tags: this.getAttribute('tags'),
published_at: this.getAttribute('published_at'),
};
}
}
Conditional Indexing
Override shouldBeSearchable() to exclude certain records from the index:
class Post extends Searchable(Model) {
static table = 'posts';
shouldBeSearchable(): boolean {
return this.getAttribute('status') === 'published';
}
}
Draft posts won't be indexed. If a published post is changed to draft, it gets removed from the index automatically.
Custom Index Name
By default the table name is used. Override getSearchableIndex():
class Post extends Searchable(Model) {
static table = 'posts';
getSearchableIndex(): string {
return 'blog_posts';
}
}
Searching
// Basic search
const results = await Post.search('hello world');
console.log(results.hits); // Array of matching documents
console.log(results.estimatedTotalHits); // Total count
// With options
const results = await Post.search('sveltekit', {
limit: 20,
offset: 0,
filter: 'status = published',
sort: ['created_at:desc'],
attributesToRetrieve: ['id', 'title', 'content'],
attributesToHighlight: ['title', 'content'],
});
// Access highlighted results
for (const hit of results.hits) {
console.log(hit._formatted?.title); // <em>SvelteKit</em> is great
}
Search Options
| Option | Type | Description |
|---|---|---|
limit |
number |
Max results to return (default: 20) |
offset |
number |
Number of results to skip |
filter |
string | string[] |
Filter expression (requires filterable attributes) |
sort |
string[] |
Sort by attributes (requires sortable attributes) |
attributesToRetrieve |
string[] |
Fields to include in results |
attributesToHighlight |
string[] |
Fields to highlight matches in |
facets |
string[] |
Faceted search attributes |
Using Search Results with Models
Search returns raw documents. To hydrate them into model instances:
const results = await Post.search('hello');
const ids = results.hits.map(hit => hit.id);
// Load full models from database
const posts = await Post.query().whereIn('id', ids).get();
Configuring Index Settings
Set filterable, sortable, and searchable attributes. Run this once (e.g., in a seeder or migration):
await Post.configureSearchIndex({
searchableAttributes: ['title', 'content', 'tags'],
filterableAttributes: ['status', 'author_id', 'category', 'published_at'],
sortableAttributes: ['created_at', 'title', 'published_at'],
displayedAttributes: ['id', 'title', 'content', 'author_name', 'created_at'],
});
You can also create a CLI command for this:
// src/lib/shared/commands/SetupSearchCommand.ts
import { Command } from '@beeblock/svelar/cli';
import { Post } from '../../modules/posts/Post.js';
export default class SetupSearchCommand extends Command {
name = 'search:setup';
description = 'Configure Meilisearch indexes';
flags = [];
async handle(): Promise<void> {
await this.bootstrap();
await Post.configureSearchIndex({
searchableAttributes: ['title', 'content', 'tags'],
filterableAttributes: ['status', 'category'],
sortableAttributes: ['created_at'],
});
this.success('Search indexes configured!');
}
}
npx svelar search:setup
Bulk Indexing
Index All Records
// Index all posts
const result = await Post.makeAllSearchable();
console.log(`Indexed ${result.indexed} posts`);
// Custom batch size (default: 500)
await Post.makeAllSearchable(1000);
Remove All from Index
await Post.removeAllFromSearch();
Index Stats
const stats = await Post.searchIndexStats();
console.log(stats.numberOfDocuments);
Skipping Index Sync
When doing bulk operations (seeding, imports, migrations), you don't want every individual save to trigger an index update. Use Search.withoutSyncing():
import { Search } from '@beeblock/svelar/search';
// No index updates during this block
await Search.withoutSyncing(async () => {
for (const row of csvData) {
await Post.create({
title: row.title,
content: row.content,
status: 'published',
});
}
});
// Re-index everything in one batch after the import
await Post.makeAllSearchable();
Common Scenarios for Skipping Sync
Database seeders:
// src/lib/database/seeders/PostSeeder.ts
import { Search } from '@beeblock/svelar/search';
import { Post } from '../../modules/posts/Post.js';
export default class PostSeeder {
async run() {
await Search.withoutSyncing(async () => {
await Post.create({ title: 'First Post', content: '...', status: 'published' });
await Post.create({ title: 'Second Post', content: '...', status: 'published' });
// ... hundreds of records
});
// One batch sync at the end
await Post.makeAllSearchable();
}
}
Bulk status updates:
await Search.withoutSyncing(async () => {
// Archive old posts — skip individual index updates
const oldPosts = await Post.query()
.where('created_at', '<', '2025-01-01')
.get();
for (const post of oldPosts) {
post.setAttribute('status', 'archived');
await post.save();
}
});
// Re-index to remove archived posts (shouldBeSearchable returns false for archived)
await Post.makeAllSearchable();
Data migrations:
await Search.withoutSyncing(async () => {
const posts = await Post.query().get();
for (const post of posts) {
// Normalize data without triggering search updates
post.setAttribute('title', post.getAttribute('title').trim());
await post.save();
}
});
await Post.makeAllSearchable();
Manual Index Control
You can manually control indexing on individual instances:
const post = await Post.find(1);
// Manually add to index
await post.searchable();
// Manually remove from index
await post.unsearchable();
Health Check
const health = await Search.health();
console.log(health.status); // 'available'
API Route Example
A search endpoint for your frontend:
// src/routes/api/search/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { Post } from '$lib/modules/posts/Post.js';
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q') ?? '';
const page = parseInt(url.searchParams.get('page') ?? '1');
const limit = 20;
if (!query.trim()) {
return json({ hits: [], total: 0 });
}
const results = await Post.search(query, {
limit,
offset: (page - 1) * limit,
filter: 'status = published',
attributesToHighlight: ['title', 'content'],
});
return json({
hits: results.hits,
total: results.estimatedTotalHits ?? 0,
page,
limit,
});
};
Full Example
// src/lib/modules/posts/Post.ts
import { Model } from '@beeblock/svelar/orm';
import { Searchable } from '@beeblock/svelar/search';
export class Post extends Searchable(Model) {
static table = 'posts';
// Only index published posts
shouldBeSearchable(): boolean {
return this.getAttribute('status') === 'published';
}
// Control which fields get indexed
toSearchableObject() {
return {
id: this.getAttribute('id'),
title: this.getAttribute('title'),
content: this.getAttribute('content'),
category: this.getAttribute('category'),
tags: this.getAttribute('tags'),
author_name: this.getAttribute('author_name'),
published_at: this.getAttribute('published_at'),
};
}
}
// src/app.ts
import { Search } from '@beeblock/svelar/search';
Search.configure({
host: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
apiKey: process.env.MEILISEARCH_KEY,
});
// Usage
const post = await Post.create({
title: 'Getting Started with Svelar',
content: 'Build SvelteKit apps the Laravel way...',
status: 'published',
category: 'tutorial',
});
// Automatically indexed in Meilisearch
const results = await Post.search('svelar tutorial');
// [{ id: 1, title: 'Getting Started with Svelar', ... }]
post.setAttribute('status', 'draft');
await post.save();
// Automatically removed from index (shouldBeSearchable returns false)