feat(search): wire idx_listing_fulltext for title+description keyword search#24
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
13050f0 to
2268378
Compare
… search
Until now the GIN(to_tsvector(...)) index from the
20250922091446_add_performance_indexes migration was orphaned —
declared in raw SQL but never referenced by any code, while every
search query went through Prisma's typed builder which can't model
the @@ operator. The index was costing writes (every Listing insert
touched the GIN) without contributing any read.
This PR makes it useful. SearchFilters gains an optional `query`
field. When set, cachedListingSearch runs a small $queryRaw against
the existing index to get matching listing IDs, then intersects the
regular Prisma where-clause with id IN (...). Two-step instead of
one big $queryRaw for the whole search because the rest of the
where-clause (price ranges, amenity arrays, date overlap subquery)
is much cleaner through the Prisma builder, and the wins from
keeping it typed are real.
Behavior:
- empty / undefined query → no full-text branch, exactly today's path
- non-empty query → GIN-backed substring + word match via
plainto_tsquery('english', ...), so "luxury beach" parses as
'luxury' & 'beach' and stop words are handled by the parser
- empty match → short-circuits to [] without the listings findMany
- extension missing → graceful fallback returns [] from the helper
URL surface:
/[lang]/listings?q=beach ← short form, conventional
/[lang]/listings?query=beach ← alias for clarity
?q + filters compose normally ← e.g. ?q=villa&priceMax=200
Search summary on the page now shows the keyword as a quoted chip
("beach") so users see what they're filtering by.
What this does NOT do:
- no UI input yet — that's the next PR. The URL contract is in
place so a search box in big-search.tsx can wire to it directly.
- no synonyms / stem language switching — the index was built with
the 'english' dictionary so we keep that. Arabic content in
title/description will tokenize on whitespace which is good enough
for now; a separate Arabic config is a future migration.
Validation:
pnpm typecheck → exit 0
pnpm vitest run → 836 / 836 (3 new tests cover the query path,
empty-match short-circuit, and the
empty-string validation rejection)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2268378 to
d347314
Compare
This was referenced May 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Turns the orphan
idx_listing_fulltextGIN index — created in20250922091446_add_performance_indexes/migration.sql— into a real keyword-search capability. Until now the index was paying for writes (everyINSERT INTO "Listing"touched it) without contributing a single read.How
SearchFiltersgains an optionalqueryfield. When set,cachedListingSearchruns a small$queryRawagainst the existing index to get matching listing IDs, then intersects the regular Prismawhere-clause withid IN (...):Two-step instead of one big
$queryRawfor the whole search because the rest of the where-clause (price ranges, amenity arrays, date-overlap subquery) is much cleaner through the Prisma builder. Keeping it typed is worth more than collapsing the round trips at this scale.URL surface
The page also adds a quoted-keyword chip to the search summary so users can see why a result set looks unfamiliar:
What this does NOT do
big-search.tsxcan wire to it as a follow-up. Server is forward-compatible.'english'dictionary so we keep that. Arabic content in title/description will tokenize on whitespace — good enough for now; a separate Arabic config is a future migration.Behavior matrix
queryvalueundefined/ not set$queryRaw, noid IN (...)filter""or" ".trim().min(1)) —searchListingsreturns{ success: false }rather than silently matching everything"beach"plainto_tsquery('english', 'beach')— index lookup, IDs intersected with filters"luxury beach"'luxury' & 'beach'— both terms required[]findMany[]→ empty result rather than 5xxValidation
pnpm typecheckpnpm vitest runTest plan after deploy
Diff stat
🤖 Generated with Claude Code