Search
Full-text search behind a portable contract: feature modules declare their indices at boot, write documents through the abstract SearchService, and query with a driver-agnostic condition language that each driver translates to its engine.
Contract
@modularityjs/search defines the abstract SearchService:
abstract class SearchService {
// Idempotent — creating an already-existing index is a no-op.
ensureIndex(definition: SearchIndexDefinition): Promise<void>;
// Deleting a missing index is a no-op.
abstract deleteIndex(name: string): Promise<void>;
abstract index<T extends SearchDocument>(
indexName: string,
id: string,
document: T,
): Promise<void>;
abstract indexBulk<T extends SearchDocument>(
indexName: string,
entries: SearchBulkEntry<T>[],
): Promise<void>;
// Returns undefined when the document (or its index) does not exist.
abstract get<T extends SearchDocument>(
indexName: string,
id: string,
): Promise<T | undefined>;
// Deleting a missing document is a no-op.
abstract delete(indexName: string, id: string): Promise<void>;
// Visibility barrier: makes prior writes visible to search.
abstract refresh(indexName: string): Promise<void>;
search<T extends SearchDocument>(
indexName: string,
query: SearchQuery,
): Promise<SearchResult<T>>;
}ensureIndex and search are template methods: the public method validates input uniformly across drivers (index names must match /^[a-z0-9][a-z0-9-_]*$/; from / size must be non-negative integers; sort order must be asc or desc — violations throw a ValidationException), then delegates to the protected doEnsureIndex / doSearch the driver implements.
Throw vs. return: get returns undefined for a missing document (routine absence the caller branches on inline); delete / deleteIndex on a missing target are no-ops; search on an index that was never ensured throws NotFoundException (broken wiring, no local recovery); transient engine failures throw StateException.
Declaring indices
Indices are declared at boot via SearchIndexesPool. Feature modules contribute a SearchIndexDefinition per index; every driver ensures the declared indices in its onInit, so boot fails loudly when the search backend is unreachable or a mapping is invalid:
import { Module } from '@modularityjs/modularity';
import { SearchIndexesPool, SearchModule } from '@modularityjs/search';
@Module({
name: 'catalog',
imports: [SearchModule],
pools: [
{
pool: SearchIndexesPool,
key: 'products',
useValue: {
name: 'products',
fields: {
title: { type: 'text' },
sku: { type: 'keyword' },
price: { type: 'number' },
inStock: { type: 'boolean' },
releasedAt: { type: 'date' },
},
},
},
],
})
class CatalogModule {}interface SearchIndexDefinition {
// Lowercase, [a-z0-9-_] — validated by SearchService.ensureIndex.
readonly name: string;
// Top-level fields only in v1 — nested paths are driver-specific.
readonly fields: Record<string, SearchFieldDefinition>;
}
interface SearchFieldDefinition {
readonly type: 'boolean' | 'date' | 'keyword' | 'number' | 'text';
}Query language
Queries use a portable condition union — drivers translate it with an exhaustive switch on kind:
type SearchCondition =
| { kind: 'and'; conditions: SearchCondition[] }
| { kind: 'match'; field: string; value: string }
| { kind: 'not'; condition: SearchCondition }
| { kind: 'or'; conditions: SearchCondition[] }
| { kind: 'prefix'; field: string; value: string }
| {
kind: 'range';
field: string;
gt?: number | string;
gte?: number | string;
lt?: number | string;
lte?: number | string;
}
| { kind: 'term'; field: string; value: boolean | number | string };
interface SearchQuery {
readonly condition?: SearchCondition; // omitted = match-all
readonly filter?: SearchCondition[]; // non-scoring, AND-ed with condition
readonly from?: number; // pagination offset, default 0
readonly size?: number; // page size, default 10
readonly sort?: SearchSort[]; // default: relevance (score descending)
}
interface SearchResult<T extends SearchDocument = SearchDocument> {
readonly total: number; // exact pre-pagination match count
readonly hits: SearchHit<T>[];
}
interface SearchHit<T extends SearchDocument = SearchDocument> {
readonly id: string;
// Relevance score; undefined under an explicit sort. Values are driver-specific.
readonly score: number | undefined;
readonly document: T;
}match scores full-text relevance; term, prefix, and range are exact predicates. Conditions in filter never contribute to the score — put "must hold but shouldn't rank" predicates there.
Drivers
Memory (@modularityjs/search-memory)
Map-based in-memory engine with tokenized match scoring. Writes are immediately visible (refresh is a no-op). For development and testing.
import { SearchModule } from '@modularityjs/search';
import { SearchMemoryModule } from '@modularityjs/search-memory';
const modules = [
SearchModule,
SearchMemoryModule,
// or with config:
SearchMemoryModule.forRoot({ maxDocumentsPerIndex: 50_000 }),
];| Option | Default | Description |
|---|---|---|
maxDocumentsPerIndex | 10_000 | Guard against unbounded memory in long-lived dev servers. Indexing beyond the cap throws a StateException. |
Elasticsearch (@modularityjs/search-elasticsearch)
Backed by @elastic/elasticsearch. Declared indices are created in onInit (the first network contact — boot fails loudly when the cluster is unreachable); the client is closed in onShutdown.
import { SearchModule } from '@modularityjs/search';
import { SearchElasticsearchModule } from '@modularityjs/search-elasticsearch';
const modules = [
SearchModule,
SearchElasticsearchModule.forRoot({
node: 'http://localhost:9200',
indexPrefix: 'myapp-',
}),
];| Option | Default | Description |
|---|---|---|
node | — | Elasticsearch node URL (required), e.g. http://localhost:9200 |
username / password | — | Basic auth |
apiKey | — | API-key auth — mutually exclusive with username/password (validated at boot) |
tlsRejectUnauthorized | true | Set false to accept self-signed certificates |
indexPrefix | '' | Prepended to every index name — isolates apps/tests sharing a cluster |
requestTimeoutMs | 30_000 | Per-request timeout in milliseconds |
The driver's conformance suite runs in the integration test lane against a real cluster (ELASTICSEARCH_URL); it skips loudly when the backend is absent locally and fails in CI.
Near-real-time visibility
Elasticsearch makes writes searchable on its refresh interval (~1s by default), not immediately. Call refresh(indexName) after writing when a subsequent search must see the write — e.g. in tests or read-your-own-writes flows. The memory and Postgres drivers are always immediately consistent.
Postgres (@modularityjs/search-postgres)
Backed by pg. Each index becomes one table (<tablePrefix><index-name>) with an id text PRIMARY KEY and a document jsonb column (GIN-indexed with jsonb_path_ops); the portable condition union translates to SQL — match runs to_tsvector('simple', …) @@ websearch_to_tsquery('simple', …) over the field, term uses jsonb containment, prefix/range compare the extracted field, and relevance ordering sums ts_rank across the query's match conditions. refresh is a no-op (Postgres is immediately consistent).
import { SearchModule } from '@modularityjs/search';
import { SearchPostgresModule } from '@modularityjs/search-postgres';
const modules = [
SearchModule,
SearchPostgresModule.forRoot({
connectionString: 'postgres://user:pass@localhost:5432/app',
}),
];| Option | Default | Description |
|---|---|---|
connectionString | — | Postgres connection URL (required) |
tablePrefix | 'search_' | Prepended to every table name — [a-z0-9_]*, isolates apps/tests |
poolSize | 10 | pg.Pool max connections |
connectionTimeoutMs | 5_000 | Pool connection acquisition timeout |
statementTimeoutMs | 30_000 | Per-statement timeout |
When to pick it over Elasticsearch: when the app already runs Postgres and search volume doesn't justify operating a second stateful system — one backup story, one connection string, zero extra infrastructure. Its trade-offs: the text-search configuration is hard-coded to 'simple' (language-independent, portable — but no stemming or per-language analyzers), relevance scores are not comparable to ES (hit.score is a placeholder 1 under relevance ordering — only the ordering is portable), and full-text match computes to_tsvector per query rather than using a persisted vector column. Reach for Elasticsearch when you need linguistic analysis, large corpora, or real relevance tuning. The driver runs the shared conformance suite in the integration lane against a real database (DATABASE_URL).
Usage
import { Inject, Injectable } from '@modularityjs/di';
import { SearchService } from '@modularityjs/search';
interface ProductDocument extends Record<string, unknown> {
title: string;
sku: string;
price: number;
inStock: boolean;
}
@Injectable()
class ProductSearchService {
constructor(@Inject(SearchService) private readonly search: SearchService) {}
async indexProduct(product: Product): Promise<void> {
await this.search.index<ProductDocument>('products', product.id, {
title: product.title,
sku: product.sku,
price: product.price,
inStock: product.inStock,
});
}
async findProducts(term: string, maxPrice: number) {
return this.search.search<ProductDocument>('products', {
condition: { kind: 'match', field: 'title', value: term },
filter: [
{ kind: 'term', field: 'inStock', value: true },
{ kind: 'range', field: 'price', lte: maxPrice },
],
size: 20,
});
}
}Bulk indexing
await this.search.indexBulk('products', [
{ id: 'p-1', document: { title: 'Keyboard', price: 49 } },
{ id: 'p-2', document: { title: 'Mouse', price: 29 } },
]);Sorting and pagination
const page = await this.search.search('products', {
condition: { kind: 'prefix', field: 'sku', value: 'KB-' },
sort: [{ field: 'price', order: 'asc' }],
from: 40,
size: 20,
});
// page.total = exact match count before pagination
// hit.score is undefined under an explicit sort