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@^0.62.0
Use the current JavaScript SDK on Node 22.12+ or 24+. Svelar lazily loads its public Meilisearch constructor while preserving the synchronous Search.getClient() API; importing the search module does not require the optional SDK to be installed. Older SDKs with the MeiliSearch spelling are not supported. Direct SDK consumers should follow the official migration guide: releases from 0.57 use ESM-only exports and the Meilisearch name.
You need a running Meilisearch instance. The default Docker setup includes Meilisearch:
npx svelar make:docker
Disable it only when unused:
npx svelar make:docker --no-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
syncDriver: 'outbox', // default: durable, after-commit synchronization
});
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.
Durable Synchronization
The default syncDriver: 'outbox' stores indexing intent in svelar_search_outbox in the same database transaction as the Model write. Run migrations before enabling it. Automatic synchronization must use the model's database connection; a separate outbox database cannot make the write atomic and is rejected.
No Meilisearch request is made in the model transaction. Queue publication happens after the outer commit and is discarded on rollback. An unavailable search engine cannot turn a committed model write into a failure. If durable intent cannot be persisted, the database write rolls back rather than silently losing synchronization.
New apps configure search when MEILISEARCH_HOST is set and register SearchOutboxSweep with their scheduler. Non-sync queues receive the registered ProcessSearchOutboxJob after commit; the periodic sweep recovers interrupted or failed dispatches and delayed retries. With a sync queue, run the scheduler or explicitly drain the outbox: the framework will not block a write on synchronous search work.
Queue publication is a nonblocking wake-up signal after commit. A stalled or unavailable producer cannot hold the already-committed HTTP response; failed publication is reported and the durable sweep recovers it. The sweep job uses explicit system context and restores each outbox entry's own origin while processing, rather than borrowing the most recent request identity.
import { Search, SearchOutboxSweep } from '@beeblock/svelar/search';
Search.configure({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_KEY,
syncDriver: 'outbox',
outbox: { maxAttempts: 10, retrySeconds: 10, leaseSeconds: 60 },
});
scheduler.register(new SearchOutboxSweep()); // Existing applications opt in explicitly.
const result = await Search.processOutbox(undefined, 100);
const stats = await Search.outbox().stats();
await Search.outbox().replay(deadEntryId);
await Search.outbox().prune(retentionCutoff);
Claims use transactional row locks with SKIP LOCKED on PostgreSQL/MySQL and serialized SQLite transactions. Owner leases renew during processing and expired claims are recoverable. Operations are ordered per document; a failed/dead earlier operation blocks later operations for that document until replay. Other documents remain processable. Retries use bounded exponential backoff and transition to a visible dead-letter state. Delivery is at least once, so synchronization operations must remain idempotent.
The processor waits for the Meilisearch task to settle before marking an entry completed; an accepted/enqueued task is not proof of successful indexing. Task failures/timeouts stay retryable. Meilisearch async task contract.
Explicit syncDriver: 'inline' is available when immediate synchronization is intentionally required. Its syncMode: 'strict' propagates errors, while 'fail-open' reports them and requires later reconciliation. Inline error propagation alone does not roll back an already committed database statement. A syncHandler(operation, next) can customize inline delivery; it is not a substitute for the transactional outbox. Removing an already-missing document is a successful no-op.
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, the serialized model attributes are indexed, honoring hidden fields. Override toSearchableObject() to explicitly select what gets sent to Meilisearch. Never include credentials or private fields in the search document:
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 on the model:
class Post extends Searchable(Model) {
static table = 'posts';
static searchIndexSettings = {
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'],
};
}
Then apply settings with the built-in CLI command:
npx svelar search:setup
The command auto-discovers Searchable models under src/lib/models and src/lib/modules. If you want a method instead of a static object, expose static searchIndexSettings() or static searchSettings().
To rebuild documents after applying settings:
npx svelar search:setup --reindex
npx svelar search:setup --fresh --batch-size=1000
If your app needs custom behavior, you can still create a user command named search:setup; app commands override the built-in command during CLI discovery.
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);
makeAllSearchable() streams records through query().chunkById(batchSize, ..., primaryKey) rather than OFFSET or loading the full table. Custom UUID/ULID/string primary keys are respected, and modifying already-processed records cannot skip the next page. Query-builder bulk writes are eventless: explicitly reindex afterward or perform individual Model writes when observers, audit and outbox effects are required.
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();
withoutSyncing() is isolated with AsyncLocalStorage, so disabling sync for one import/request does not disable it for unrelated concurrent work in the same Node process.
Common Scenarios for Skipping Sync
Database seeders:
// src/lib/database/seeders/PostSeeder.ts
import { Search } from '@beeblock/svelar/search';
import { Post } from '../../modules/posts/domain/models/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/domain/models/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/domain/models/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)