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:
Zimin A.N.
2026-05-08 12:53:44 +03:00
parent 08405fecf7
commit 48df5ceeb8
10 changed files with 489 additions and 0 deletions
+21
View File
@@ -246,6 +246,27 @@ export type WebhookDeliveryPage = {
size: number
}
// === Smart search (CEO plan v1) ===
export type SearchItem = {
businessKey: string
dataScope: string
createdAt: string
}
export type SearchDictGroup = {
dictName: string
dictDisplayName?: string | null
count: number
items: SearchItem[]
}
export type SearchResponse = {
query: string
total: number
groups: SearchDictGroup[]
}
// === Dependents (Phase 1 dict-relationships-v2) ===
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
+25
View File
@@ -12,6 +12,7 @@ import {
type RecordDependentsPage,
type RecordResponse,
type SchemaDependent,
type SearchResponse,
type WebhookDeliveryPage,
type WebhookSubscription,
} from './client'
@@ -363,6 +364,30 @@ export const useCascadePreview = (dict: string, businessKey: string | undefined)
enabled: Boolean(businessKey),
})
// === Smart search (CEO plan v1) ===
/**
* Free-form ILIKE search across all dictionaries (active records only).
* Min query length = 3 (backend требует для использования trigram index).
*/
export const searchQuery = (q: string, size = 100, perDict = 10) =>
queryOptions({
queryKey: ['search', q, size, perDict] as const,
queryFn: async (): Promise<SearchResponse> => {
const { data } = await apiClient.get<SearchResponse>('/search', {
params: { q, size, perDict },
})
return data
},
staleTime: 60_000,
})
export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
useQuery({
...searchQuery(q ?? '', size, perDict),
enabled: Boolean(q && q.length >= 3),
})
export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (
+20
View File
@@ -17,6 +17,17 @@ i18n
'nav.audit': 'Аудит',
'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'nav.search': 'Поиск',
'search.title': 'Smart search',
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
'search.tooShort': 'Минимум 3 символа для запуска поиска.',
'search.empty': 'Ничего не найдено. Попробуйте другой запрос.',
'search.totalHits_one': 'Найдено: {{count}} запись',
'search.totalHits_few': 'Найдено: {{count}} записи',
'search.totalHits_many': 'Найдено: {{count}} записей',
'search.totalHits_other': 'Найдено: {{count}} записей',
'search.intro': 'Введите запрос и нажмите Enter. Группировка результатов по справочнику; max 10 на справочник, 100 всего.',
'webhooks.title': 'Webhook subscriptions',
'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам',
'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.',
@@ -378,6 +389,15 @@ i18n
'nav.audit': 'Audit',
'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'nav.search': 'Search',
'search.title': 'Smart search',
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
'search.placeholder': 'Type 3+ chars: code, name, identifier…',
'search.tooShort': 'Type at least 3 characters to run search.',
'search.empty': 'No results. Try a different query.',
'search.totalHits_one': '{{count}} record found',
'search.totalHits_other': '{{count}} records found',
'search.intro': 'Type a query and press Enter. Results grouped per dictionary; max 10 per dictionary, 100 total.',
'webhooks.title': 'Webhook subscriptions',
'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers',
'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.',
+21
View File
@@ -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
+7
View File
@@ -51,6 +51,13 @@ function RootLayout() {
>
{t('nav.webhooks')}
</Link>
<Link
to="/search"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.search')}
</Link>
</nav>
<div className="ml-auto flex items-center gap-3">
<LanguageSwitch
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
EmptyState,
LoadingBlock,
PageHeader,
Panel,
SearchInput,
} from '@nstart/ui'
import { useSearch } from '@/api/queries'
type SearchSearch = { q?: string }
const validateSearch = (raw: Record<string, unknown>): SearchSearch => {
if (typeof raw.q === 'string' && raw.q.length > 0) return { q: raw.q }
return {}
}
export const Route = createFileRoute('/search')({
component: SearchPage,
validateSearch,
})
/**
* Smart JSONB search across all dictionaries (CEO plan v1 stretch).
*
* <p>Backend: GET /api/v1/search?q=&size&perDict — ILIKE с trigram-индексом
* на dictionary_records.data::text. Min query length = 3 (trigram threshold).
*
* <p>UI: search input → grouped results per dict с per-row link на full
* record. URL state ?q=… — share-friendly.
*/
function SearchPage() {
const { t } = useTranslation()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const [input, setInput] = useState(urlSearch.q ?? '')
const result = useSearch(urlSearch.q)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = input.trim()
void navigate({ search: trimmed ? { q: trimmed } : {}, replace: true })
}
const tooShort = input.length > 0 && input.length < 3
const hasQuery = Boolean(urlSearch.q && urlSearch.q.length >= 3)
return (
<div className="space-y-4">
<PageHeader
title={t('search.title')}
description={t('search.description')}
/>
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="flex-1 max-w-2xl">
<SearchInput
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('search.placeholder')}
aria-label={t('search.placeholder')}
autoFocus
/>
</div>
</form>
{tooShort && (
<p className="text-2xs text-carbon/60">{t('search.tooShort')}</p>
)}
{hasQuery && result.isLoading && (
<LoadingBlock size="md" label={t('loading')} />
)}
{hasQuery && result.error && (
<Alert variant="error" title={t('error.failed')}>
{String(result.error)}
</Alert>
)}
{hasQuery && result.data && result.data.total === 0 && (
<EmptyState title={t('search.empty')} />
)}
{hasQuery && result.data && result.data.total > 0 && (
<>
<p className="text-2xs text-carbon/60">
{t('search.totalHits', { count: result.data.total })}
</p>
<div className="space-y-3">
{result.data.groups.map((g) => (
<Panel key={g.dictName}>
<div className="flex items-center justify-between mb-2">
<Link
to="/dictionaries/$name"
params={{ name: g.dictName }}
search={{ q: urlSearch.q }}
className="text-sm font-primary text-ultramarain hover:underline"
>
{g.dictDisplayName ?? g.dictName}
</Link>
<Badge variant="neutral">{g.count}</Badge>
</div>
<ul className="divide-y divide-regolith">
{g.items.map((it) => (
<li
key={`${g.dictName}-${it.businessKey}`}
className="flex items-center justify-between gap-3 py-2 text-2xs"
>
<Link
to="/dictionaries/$name"
params={{ name: g.dictName }}
search={{ q: it.businessKey }}
className="font-mono text-ultramarain hover:underline truncate"
>
{it.businessKey}
</Link>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="neutral">{it.dataScope}</Badge>
<span className="text-carbon/60">
{new Date(it.createdAt).toLocaleString()}
</span>
</div>
</li>
))}
</ul>
</Panel>
))}
</div>
</>
)}
{!hasQuery && !tooShort && (
<p className="text-2xs text-carbon/60">{t('search.intro')}</p>
)}
</div>
)
}