Search Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
search-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Two packages compose:
@modularityjs/search—SearchServiceabstract contract +SearchModule+SearchIndexesPool. Methods:ensureIndex(definition)(idempotent),deleteIndex(name),index<T>(indexName, id, document),indexBulk<T>(indexName, entries),get<T>(indexName, id) → T | undefined,delete(indexName, id),refresh(indexName)(visibility barrier),search<T>(indexName, query) → SearchResult<T>({ total, hits: [{ id, score, document }] }—totalis the exact pre-pagination match count).@modularityjs/search-{memory,elasticsearch}— drivers. Memory is per-process (capped atSearchMemoryConfig.maxDocumentsPerIndex, default 10 000); Elasticsearch configures viaSearchElasticsearchModule.forRoot({ node, indexPrefix, ... }). Both bind viapreferences: [{ provide: SearchService, useClass: ... }].
Indices are declared, not created imperatively: contribute a SearchIndexDefinition to SearchIndexesPool — a value pool (useValue: { name, fields }). The active driver ensureIndexes every declared definition in its onInit, so boot fails loudly when the search backend is unreachable. Index names must match [a-z0-9][a-z0-9-_]*; field types are 'text' | 'keyword' | 'number' | 'boolean' | 'date' — text is analyzed (use match), keyword is exact (use term / prefix).
Queries use a portable condition union — { kind: 'match' | 'term' | 'prefix' | 'range' | 'and' | 'or' | 'not', ... } — that every driver translates with an exhaustive switch, so the same query object runs against memory and Elasticsearch. filter holds non-scoring conditions AND-ed with condition; omitting condition means match-all.
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import {
SearchIndexesPool,
SearchModule,
SearchService,
type SearchResult,
} from '@modularityjs/search';
import { SearchMemoryModule } from '@modularityjs/search-memory';
type Article = {
title: string;
category: string;
views: number;
};
@Injectable()
class ArticleSearchService {
constructor(@Inject(SearchService) private readonly search: SearchService) {}
async publish(id: string, article: Article): Promise<void> {
await this.search.index('articles', id, article);
}
async find(text: string, category?: string): Promise<SearchResult<Article>> {
return this.search.search<Article>('articles', {
condition: { kind: 'match', field: 'title', value: text },
filter: category
? [{ kind: 'term', field: 'category', value: category }]
: [],
sort: [{ field: 'views', order: 'desc' }],
size: 20,
});
}
}
@Module({
name: 'article-search',
imports: [SearchModule],
providers: [ArticleSearchService],
pools: [
{
pool: SearchIndexesPool,
key: 'articles',
useValue: {
name: 'articles',
fields: {
title: { type: 'text' },
category: { type: 'keyword' },
views: { type: 'number' },
},
},
},
],
})
class AppModule {}
const app = await createApp({
di: inversify,
modules: [ModularityModule, SearchModule, SearchMemoryModule, AppModule],
});Switch to Elasticsearch with one line. Replace SearchMemoryModule with SearchElasticsearchModule.forRoot({ node: 'http://localhost:9200' }) — ArticleSearchService is unchanged. Set indexPrefix to isolate apps or tests sharing a cluster (it's prepended to every index name transparently).
refresh before read-your-writes searches. Elasticsearch is near-real-time — a document indexed a moment ago may not be searchable yet. Call refresh(indexName) after writes when the next search must see them (tests, index-then-list flows); don't call it per-write in hot paths. The memory driver is immediately consistent, so code that refreshes stays portable.
Throw-vs-return split. get returns undefined for a missing document; delete / deleteIndex on a missing target are no-ops; but search against an index that was never ensured throws NotFoundException — that's broken wiring (a missing pool entry), not routine absence. Under an explicit sort, hit.score is undefined; score values are driver-specific, so never compare them across drivers.