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-meilisearch

Register 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();