feat(search): smart JSONB search across all dictionaries
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as WebhooksRouteImport } from './routes/webhooks'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as OutboxRouteImport } from './routes/outbox'
|
||||
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
||||
import { Route as AuditRouteImport } from './routes/audit'
|
||||
@@ -24,6 +25,11 @@ const WebhooksRoute = WebhooksRouteImport.update({
|
||||
path: '/webhooks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SearchRoute = SearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OutboxRoute = OutboxRouteImport.update({
|
||||
id: '/outbox',
|
||||
path: '/outbox',
|
||||
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath {
|
||||
'/audit': typeof AuditRoute
|
||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||
'/outbox': typeof OutboxRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/webhooks': typeof WebhooksRouteWithChildren
|
||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||
'/webhooks/$id': typeof WebhooksIdRoute
|
||||
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/audit': typeof AuditRoute
|
||||
'/outbox': typeof OutboxRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||
'/webhooks/$id': typeof WebhooksIdRoute
|
||||
'/dictionaries': typeof DictionariesIndexRoute
|
||||
@@ -91,6 +99,7 @@ export interface FileRoutesById {
|
||||
'/audit': typeof AuditRoute
|
||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||
'/outbox': typeof OutboxRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/webhooks': typeof WebhooksRouteWithChildren
|
||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||
'/webhooks/$id': typeof WebhooksIdRoute
|
||||
@@ -104,6 +113,7 @@ export interface FileRouteTypes {
|
||||
| '/audit'
|
||||
| '/dictionaries'
|
||||
| '/outbox'
|
||||
| '/search'
|
||||
| '/webhooks'
|
||||
| '/dictionaries/$name'
|
||||
| '/webhooks/$id'
|
||||
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/audit'
|
||||
| '/outbox'
|
||||
| '/search'
|
||||
| '/dictionaries/$name'
|
||||
| '/webhooks/$id'
|
||||
| '/dictionaries'
|
||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
||||
| '/audit'
|
||||
| '/dictionaries'
|
||||
| '/outbox'
|
||||
| '/search'
|
||||
| '/webhooks'
|
||||
| '/dictionaries/$name'
|
||||
| '/webhooks/$id'
|
||||
@@ -136,6 +148,7 @@ export interface RootRouteChildren {
|
||||
AuditRoute: typeof AuditRoute
|
||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||
OutboxRoute: typeof OutboxRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
WebhooksRoute: typeof WebhooksRouteWithChildren
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof WebhooksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/search': {
|
||||
id: '/search'
|
||||
path: '/search'
|
||||
fullPath: '/search'
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/outbox': {
|
||||
id: '/outbox'
|
||||
path: '/outbox'
|
||||
@@ -240,6 +260,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AuditRoute: AuditRoute,
|
||||
DictionariesRoute: DictionariesRouteWithChildren,
|
||||
OutboxRoute: OutboxRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
WebhooksRoute: WebhooksRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
Reference in New Issue
Block a user