Search
Use SearchPort when application code needs to index and query searchable read
models without depending on a specific search service.
Search indexes are application read models. Keep writes, authorization, and business invariants in your database and use search for discovery, filtering, sorting, facets, and ranking.
Define an index
Define index metadata near the feature that owns the searchable document:
// features/issues/search.ts
import { defineSearchIndex } from "@beignet/core/search";
export type IssueSearchDocument = {
id: string;
tenantId: string;
key: string;
title: string;
status: "open" | "resolved";
createdAt: string;
};
export const issueSearchIndex = defineSearchIndex<IssueSearchDocument>(
"issues",
{
searchableAttributes: ["key", "title"],
filterableAttributes: ["tenantId", "status"],
sortableAttributes: ["createdAt"],
},
);Index documents
Use cases, listeners, jobs, and tasks can index documents through the same port:
await ctx.ports.search.indexDocuments(issueSearchIndex, {
id: issue.id,
tenantId: issue.tenantId,
key: issue.key,
title: issue.title,
status: issue.status,
createdAt: issue.createdAt,
});For durable indexing, prefer after-commit listeners or outbox-backed workflows so the search index follows committed database state. Use tasks for backfills:
await ctx.ports.search.configureIndex(issueSearchIndex);
await ctx.ports.search.indexDocuments(
issueSearchIndex,
issues.map(issueToSearchDocument),
);Query documents
const results = await ctx.ports.search.search(issueSearchIndex, {
query: "billing",
filters: {
tenantId,
status: "open",
},
sort: ["createdAt:desc"],
facets: ["status"],
limit: 20,
offset: 0,
});filters are provider-neutral exact-match filters. Providers translate them to
their native query language. Keep user authorization in use cases and policies;
include tenant or visibility filters when the index contains multi-tenant data.
Setup with Meilisearch
Install the Meilisearch search provider:
bun add @beignet/provider-search-meilisearchRegister it in server/providers.ts:
import { createMeilisearchSearchProvider } from "@beignet/provider-search-meilisearch";
export const providers = [
createMeilisearchSearchProvider({
indexPrefix: "my-app",
}),
];Set MEILISEARCH_HOST in production. Optional env vars include
MEILISEARCH_API_KEY, MEILISEARCH_INDEX_PREFIX, and
MEILISEARCH_TIMEOUT_MS.
The provider contributes ctx.ports.search and ctx.ports.meilisearch as an
escape hatch with the raw client and configured index prefix.
Testing
createTestPorts(...) includes an in-memory search port by default:
const { ports, search } = createTestPorts<AppPorts>();
await ports.search.indexDocuments(issueSearchIndex, issueDocument);
await expect(
search.search(issueSearchIndex, {
query: "billing",
filters: { tenantId: "tenant_example" },
}),
).resolves.toMatchObject({
hits: [issueDocument],
});You can also import the memory adapter directly:
import { createMemorySearch } from "@beignet/core/search";
const search = createMemorySearch();Related pages
- Ports and adapters for app-facing dependency boundaries.
- Tasks for search backfills.
- Outbox for durable post-commit indexing.
- Providers for provider setup and escape hatches.