@area37/vendure-plugin-default-search-plus
v1.1.0
Published
Adds price-range filtering, price facets (range + histogram) and optional pg_trgm fuzzy search to Vendure's DefaultSearchPlugin (Postgres) — the Elasticsearch-only search features, without Elasticsearch
Maintainers
Readme
Vendure DefaultSearch Plus
Extends Vendure's built-in DefaultSearchPlugin (Postgres) with the search
features that otherwise only the Elasticsearch plugin provides — without an
external search server:
- Price-range filtering —
priceRange/priceRangeWithTaxonSearchInput - Price aggregation —
prices { range, rangeWithTax, buckets, bucketsWithTax }onSearchResponse(for price sliders / histograms), resolved lazily - Optional fuzzy (typo-tolerant) matching via the PostgreSQL
pg_trgmextension - Optional cross-language matching — a term in one language finds products named in another, still displayed in the visitor's language
It plugs into DefaultSearchPlugin as a custom searchStrategy (extending the
public PostgresSearchStrategy) plus a small set of shop-api resolvers. The
standard search query, facets, collections, sorting and inStock keep working
unchanged.
Postgres only. Requires
@vendure/core^3.1.0.
Install
npm install @area37/vendure-plugin-default-search-plusUsage
import { DefaultSearchPlugin } from '@vendure/core';
import { PostgresPriceSearchPlugin } from '@area37/vendure-plugin-default-search-plus';
// Call .init() first so `searchStrategy` exists when DefaultSearchPlugin reads it.
const priceSearch = PostgresPriceSearchPlugin.init({
indexStockStatus: true, // must match DefaultSearchPlugin below
fuzzy: true, // requires the pg_trgm extension (see below)
fuzzyThreshold: 0.25,
priceRangeBucketCount: 10,
crossLanguage: true, // search every language, display the current one
textSearchConfig: 'simple',
});
export const config: VendureConfig = {
plugins: [
DefaultSearchPlugin.init({
indexStockStatus: true,
searchStrategy: PostgresPriceSearchPlugin.searchStrategy,
}),
priceSearch,
],
};Query:
query {
search(input: { collectionSlug: "watches", priceRangeWithTax: { min: 0, max: 500000 } }) {
totalItems
items { productName priceWithTax { ... on PriceRange { min max } } inStock }
prices { rangeWithTax { min max } buckets { to count } }
}
}Fuzzy search (pg_trgm)
fuzzy: true adds a pg_trgm word_similarity fallback to the term match so
typos still return results (exact/prefix matches still rank highest). It requires
the extension — run once (e.g. in a migration):
CREATE EXTENSION IF NOT EXISTS pg_trgm;With fuzzy: false (the default) the strategy behaves exactly like the stock
PostgresSearchStrategy term matching and pg_trgm is not required.
Cross-language search
On a multilingual shop the stock strategy searches one language: the row for
the current language, or — outside the channel's default language — the current
language falling back to the default. So on a Russian storefront "minecraft"
finds nothing, even though the product is indexed as Minecraft in English and
Mainkrafts in Latvian.
crossLanguage: true matches the term against the same variant's rows in every
language while still returning the row for the current one. The visitor types in
whatever language they think in and gets results labelled in theirs.
PostgresPriceSearchPlugin.init({ crossLanguage: true, textSearchConfig: 'simple' });Vendure already writes one index row per available language, so there is nothing extra to index — but do create the indexes below, and note:
- No dilution. A term in the page's own language returns exactly the same set as before; the extra branch only adds products that were unreachable.
- Ranking. Cross-language-only hits get no
ts_rank_cdscore, so they sort after same-language matches. - Fuzzy stays same-language.
word_similarityinside a correlated subquery cannot use an index (measured ~8x slower), and typos in a language the visitor does not write are not worth that. The cross-language branch is exact/prefix.
Indexes
to_tsvector(col) — the single-argument form the stock strategy emits — is only
STABLE, so it can never be indexed. Setting textSearchConfig switches to the
IMMUTABLE two-argument form, which makes GIN expression indexes possible:
import { searchIndexDdl } from '@area37/vendure-plugin-default-search-plus';
// in a Vendure migration
for (const sql of searchIndexDdl({ textSearchConfig: 'simple', crossLanguage: true })) {
await queryRunner.query(sql);
}The DDL is generated from the same config the strategy uses, because Postgres
only picks up an expression index when the expressions match exactly. On a
2,600-variant catalogue this took a cross-language term query from 650 ms to
~15 ms. searchIndexDdlDown() returns the DROP INDEX counterparts.
Nothing breaks without the indexes — queries just fall back to a sequential scan.
Options
| Option | Default | Description |
| --- | --- | --- |
| indexStockStatus | false | Set to match DefaultSearchPlugin.init({ indexStockStatus }). |
| fuzzy | false | Enable pg_trgm typo tolerance (requires the extension). |
| fuzzyThreshold | 0.25 | word_similarity threshold [0..1]; lower = more permissive. Values below ~0.4 pull in unrelated products. |
| crossLanguage | false | Match the term against every language's index rows; display the current language. |
| textSearchConfig | 'simple' | Text-search config for to_tsvector/to_tsquery. null emits the un-indexable single-argument form (pre-1.1 behaviour). |
| priceRangeBucketCount | 10 | Buckets returned in prices.buckets. |
Notes / limitations
- Postgres only (uses
width_bucket,to_tsquery, and — for fuzzy —word_similarity). - After enabling, reindex the search index (
reindexmutation / dashboard). fuzzymatching is never index-backed (word_similarity(a, b) > tis not an indexable predicate, and OR-ing it in prevents the GIN indexes from being used for the whole term clause). Budget for a sequential scan when it is on.
Compatibility & maintenance
Tested against @vendure/core 3.1.x and 3.7.x (e2e). The strategy extends
the public PostgresSearchStrategy and references only the search_index_item
table name — no deep dist/ imports — so it is reasonably portable across
Vendure 3.x.
One thing to know: Vendure's PostgresSearchStrategy.applyTermAndFilters is
private, so this plugin overrides it with a copy of that method (based on the
3.7 implementation) plus the price/fuzzy additions. If a future Vendure release
changes term/facet/collection query building in that method, the copy can drift
and would need to be re-synced. The e2e test guards against regressions — run it
against the Vendure version you target before upgrading:
DB=postgres yarn e2e