@fayz-ai/core 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-XJKW2JRW.js → chunk-GULZD7XW.js} +7 -14
- package/dist/chunk-GULZD7XW.js.map +1 -0
- package/dist/{chunk-SO3VUKGZ.cjs → chunk-HN66DF4E.cjs} +123 -4
- package/dist/chunk-HN66DF4E.cjs.map +1 -0
- package/dist/{chunk-CYAWDW44.cjs → chunk-KL2XT2TV.cjs} +6 -16
- package/dist/chunk-KL2XT2TV.cjs.map +1 -0
- package/dist/{chunk-JHGXROKV.js → chunk-MJ4ML56F.js} +123 -4
- package/dist/chunk-MJ4ML56F.js.map +1 -0
- package/dist/data/archetype.d.ts.map +1 -1
- package/dist/data/index.cjs +9 -21
- package/dist/data/index.d.ts +0 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/refresh.d.ts +36 -0
- package/dist/data/refresh.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +6 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/i18n/index.cjs +10 -10
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/shell-translations.d.ts.map +1 -1
- package/dist/index.cjs +694 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +652 -59
- package/dist/index.js.map +1 -1
- package/dist/search/engine.d.ts +12 -0
- package/dist/search/engine.d.ts.map +1 -0
- package/dist/search/index.d.ts +5 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/text.d.ts +37 -0
- package/dist/search/text.d.ts.map +1 -0
- package/dist/search/types.d.ts +72 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/types/org.d.ts +28 -0
- package/dist/types/org.d.ts.map +1 -1
- package/dist/types/plugins.d.ts +12 -0
- package/dist/types/plugins.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/data/archetype.ts +4 -2
- package/src/data/index.ts +0 -1
- package/src/data/refresh.ts +105 -0
- package/src/data/supabase.ts +3 -1
- package/src/data/types.ts +6 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/shell-translations.ts +119 -2
- package/src/index.ts +19 -2
- package/src/search/engine.ts +551 -0
- package/src/search/index.ts +25 -0
- package/src/search/text.ts +206 -0
- package/src/search/types.ts +75 -0
- package/src/types/org.ts +29 -0
- package/src/types/plugins.ts +12 -0
- package/dist/chunk-CYAWDW44.cjs.map +0 -1
- package/dist/chunk-JHGXROKV.js.map +0 -1
- package/dist/chunk-SO3VUKGZ.cjs.map +0 -1
- package/dist/chunk-XJKW2JRW.js.map +0 -1
- package/dist/data/cloud.d.ts +0 -5
- package/dist/data/cloud.d.ts.map +0 -1
- package/src/data/cloud.ts +0 -28
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import type { EntityDef } from '../types/crud'
|
|
2
|
+
import { resolveDataProvider } from '../data/resolve'
|
|
3
|
+
import { getSupabaseClientOptional } from '../data/supabase'
|
|
4
|
+
import { getActiveTenantId } from '../tenant'
|
|
5
|
+
import {
|
|
6
|
+
digitsOf,
|
|
7
|
+
foldText,
|
|
8
|
+
normalizeQuery,
|
|
9
|
+
scoreCandidate,
|
|
10
|
+
type NormalizedQuery,
|
|
11
|
+
} from './text'
|
|
12
|
+
import type {
|
|
13
|
+
GlobalSearchResult,
|
|
14
|
+
SearchGroup,
|
|
15
|
+
SearchHit,
|
|
16
|
+
SearchOptions,
|
|
17
|
+
SearchPath,
|
|
18
|
+
SearchTarget,
|
|
19
|
+
} from './types'
|
|
20
|
+
|
|
21
|
+
// Global search, two paths, one ranking:
|
|
22
|
+
// ① INDEX — one `public.fayz_global_search` RPC (trigram-indexed, ~6ms).
|
|
23
|
+
// ② SCAN — parallel ilike per entity. Fallback for pools without the
|
|
24
|
+
// migration, for mock data, and whenever ① errors.
|
|
25
|
+
// Both feed the same scorer, so the path changes latency, never the order.
|
|
26
|
+
|
|
27
|
+
/** Nothing shorter is worth a round-trip. */
|
|
28
|
+
export const MIN_QUERY_LENGTH = 2
|
|
29
|
+
|
|
30
|
+
const DEFAULT_LIMIT = 30
|
|
31
|
+
const DEFAULT_PER_TARGET = 5
|
|
32
|
+
/** Wide enough that a typical app fits in one wave — a second wave waits on
|
|
33
|
+
* the slowest table of the first. */
|
|
34
|
+
const SCAN_CONCURRENCY = 16
|
|
35
|
+
/** A single slow table must not hold the box hostage. */
|
|
36
|
+
const TARGET_TIMEOUT_MS = 3000
|
|
37
|
+
const CACHE_TTL_MS = 30_000
|
|
38
|
+
const CACHE_MAX_ENTRIES = 40
|
|
39
|
+
|
|
40
|
+
const RPC_NAME = 'fayz_global_search'
|
|
41
|
+
|
|
42
|
+
/** null = never asked. Set false once Postgres says the RPC is missing. */
|
|
43
|
+
let indexAvailable: boolean | null = null
|
|
44
|
+
|
|
45
|
+
/** Test seam / manual override. */
|
|
46
|
+
export function setSearchIndexAvailable(value: boolean | null): void {
|
|
47
|
+
indexAvailable = value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isSearchIndexAvailable(): boolean | null {
|
|
51
|
+
return indexAvailable
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rows come back camelCase, EntityDefs declare both spellings — look up either.
|
|
55
|
+
|
|
56
|
+
function camelOf(key: string): string {
|
|
57
|
+
return key.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function snakeOf(key: string): string {
|
|
61
|
+
return key.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readField(row: Record<string, unknown>, key: string): unknown {
|
|
65
|
+
if (key in row) return row[key]
|
|
66
|
+
const camel = camelOf(key)
|
|
67
|
+
if (camel in row) return row[camel]
|
|
68
|
+
const snake = snakeOf(key)
|
|
69
|
+
if (snake in row) return row[snake]
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function asText(value: unknown): string {
|
|
74
|
+
if (typeof value === 'string') return value
|
|
75
|
+
if (typeof value === 'number') return String(value)
|
|
76
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === 'string').join(' ')
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Field keys worth matching against, in declaration order. */
|
|
81
|
+
function searchableKeys(entity: EntityDef): string[] {
|
|
82
|
+
const declared = entity.data?.searchColumns ?? entity.fields.filter((f) => f.searchable).map((f) => f.key)
|
|
83
|
+
const keys = [...declared]
|
|
84
|
+
const display = entity.displayField ?? 'name'
|
|
85
|
+
if (!keys.includes(display)) keys.unshift(display)
|
|
86
|
+
const subtitle = entity.subtitleField
|
|
87
|
+
if (subtitle && !keys.includes(subtitle)) keys.push(subtitle)
|
|
88
|
+
return keys
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Keys whose value is a number the user might type digits of. */
|
|
92
|
+
const DIGIT_FIELD_TYPES = new Set(['phone', 'number'])
|
|
93
|
+
|
|
94
|
+
function digitKeys(entity: EntityDef): string[] {
|
|
95
|
+
const keys = entity.fields
|
|
96
|
+
.filter((f) => DIGIT_FIELD_TYPES.has(f.type) || /phone|document|cpf|cnpj|zip|postal|sku|code|reference/i.test(f.key))
|
|
97
|
+
.map((f) => f.key)
|
|
98
|
+
for (const extra of ['phone', 'document_number', 'sku', 'reference_number', 'postal_code']) {
|
|
99
|
+
if (!keys.includes(extra)) keys.push(extra)
|
|
100
|
+
}
|
|
101
|
+
return keys
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function titleOf(entity: EntityDef, row: Record<string, unknown>): string {
|
|
105
|
+
const display = entity.displayField ?? 'name'
|
|
106
|
+
const primary = asText(readField(row, display))
|
|
107
|
+
if (primary) return primary
|
|
108
|
+
for (const field of entity.fields) {
|
|
109
|
+
if (field.type === 'text' || field.type === 'email') {
|
|
110
|
+
const value = asText(readField(row, field.key))
|
|
111
|
+
if (value) return value
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return asText(row.id) || '—'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function subtitleOf(entity: EntityDef, row: Record<string, unknown>, title: string): string | undefined {
|
|
118
|
+
// A subtitle that repeats the title is noise.
|
|
119
|
+
const distinct = (value: string): string | undefined => {
|
|
120
|
+
if (!value || foldText(value) === foldText(title)) return undefined
|
|
121
|
+
return value.length > 90 ? value.slice(0, 89) + '…' : value
|
|
122
|
+
}
|
|
123
|
+
const declared = entity.subtitleField
|
|
124
|
+
if (declared) {
|
|
125
|
+
const value = distinct(asText(readField(row, declared)))
|
|
126
|
+
if (value) return value
|
|
127
|
+
}
|
|
128
|
+
for (const key of ['email', 'phone', 'sku', 'reference_number', 'document_number', 'description']) {
|
|
129
|
+
const value = distinct(asText(readField(row, key)))
|
|
130
|
+
if (value) return value
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Result assembly
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function hitFromRow(
|
|
140
|
+
target: SearchTarget,
|
|
141
|
+
row: Record<string, unknown>,
|
|
142
|
+
query: NormalizedQuery,
|
|
143
|
+
): SearchHit | null {
|
|
144
|
+
const id = asText(row.id)
|
|
145
|
+
if (!id) return null
|
|
146
|
+
const entity = target.entity
|
|
147
|
+
const title = titleOf(entity, row)
|
|
148
|
+
const parts: string[] = [title]
|
|
149
|
+
for (const key of searchableKeys(entity)) {
|
|
150
|
+
const value = asText(readField(row, key))
|
|
151
|
+
if (value) parts.push(value)
|
|
152
|
+
}
|
|
153
|
+
let digits = ''
|
|
154
|
+
for (const key of digitKeys(entity)) {
|
|
155
|
+
const value = readField(row, key)
|
|
156
|
+
if (typeof value === 'string' || typeof value === 'number') digits += digitsOf(String(value))
|
|
157
|
+
}
|
|
158
|
+
const score = scoreCandidate(query, {
|
|
159
|
+
title: foldText(title),
|
|
160
|
+
haystack: foldText(parts.join(' ')),
|
|
161
|
+
digits,
|
|
162
|
+
})
|
|
163
|
+
if (score === 0) return null
|
|
164
|
+
return {
|
|
165
|
+
uid: `${target.key}:${id}`,
|
|
166
|
+
id,
|
|
167
|
+
key: target.key,
|
|
168
|
+
group: target.label,
|
|
169
|
+
icon: target.icon ?? entity.icon,
|
|
170
|
+
title,
|
|
171
|
+
subtitle: subtitleOf(entity, row, title),
|
|
172
|
+
score: score * (target.boost ?? 1),
|
|
173
|
+
archetype: entity.data?.archetype,
|
|
174
|
+
archetypeKind: entity.data?.archetypeKind,
|
|
175
|
+
table: entity.data?.table,
|
|
176
|
+
record: row,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
181
|
+
|
|
182
|
+
/** Identity of the RECORD, not of the target that found it — several targets
|
|
183
|
+
* cover the same rows ("Fornecedores" and "Suppliers" are both people). */
|
|
184
|
+
function recordIdentity(hit: SearchHit): string {
|
|
185
|
+
if (UUID.test(hit.id)) return hit.id
|
|
186
|
+
return `${hit.table ?? hit.key}#${hit.id}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assemble(
|
|
190
|
+
query: string,
|
|
191
|
+
hits: SearchHit[],
|
|
192
|
+
targets: SearchTarget[],
|
|
193
|
+
opts: { via: SearchPath; failed: string[]; elapsedMs: number; perTarget: number; limit: number; partial?: boolean },
|
|
194
|
+
): GlobalSearchResult {
|
|
195
|
+
const seen = new Set<string>()
|
|
196
|
+
const unique: SearchHit[] = []
|
|
197
|
+
for (const hit of hits.slice().sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))) {
|
|
198
|
+
const identity = recordIdentity(hit)
|
|
199
|
+
if (seen.has(identity)) continue
|
|
200
|
+
seen.add(identity)
|
|
201
|
+
unique.push(hit)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Cap per target before the global cap, so one populous entity can't crowd out the rest.
|
|
205
|
+
const byKey = new Map<string, SearchHit[]>()
|
|
206
|
+
const kept: SearchHit[] = []
|
|
207
|
+
const capped = new Set<string>()
|
|
208
|
+
for (const hit of unique) {
|
|
209
|
+
const bucket = byKey.get(hit.key) ?? []
|
|
210
|
+
if (bucket.length >= opts.perTarget) { capped.add(hit.key); continue }
|
|
211
|
+
bucket.push(hit)
|
|
212
|
+
byKey.set(hit.key, bucket)
|
|
213
|
+
kept.push(hit)
|
|
214
|
+
}
|
|
215
|
+
const limited = kept.slice(0, opts.limit)
|
|
216
|
+
|
|
217
|
+
const order = new Map(targets.map((t, i) => [t.key, i]))
|
|
218
|
+
const groups: SearchGroup[] = []
|
|
219
|
+
for (const hit of limited) {
|
|
220
|
+
let group = groups.find((g) => g.key === hit.key)
|
|
221
|
+
if (!group) {
|
|
222
|
+
group = { key: hit.key, label: hit.group, icon: hit.icon, hits: [], hasMore: capped.has(hit.key) }
|
|
223
|
+
groups.push(group)
|
|
224
|
+
}
|
|
225
|
+
group.hits.push(hit)
|
|
226
|
+
}
|
|
227
|
+
// Groups follow their best hit; ties fall back to target order.
|
|
228
|
+
groups.sort((a, b) => (b.hits[0]?.score ?? 0) - (a.hits[0]?.score ?? 0)
|
|
229
|
+
|| (order.get(a.key) ?? 99) - (order.get(b.key) ?? 99))
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
query,
|
|
233
|
+
hits: limited,
|
|
234
|
+
groups,
|
|
235
|
+
via: opts.via,
|
|
236
|
+
failed: opts.failed,
|
|
237
|
+
elapsedMs: opts.elapsedMs,
|
|
238
|
+
partial: opts.partial ?? false,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Cache: exact hits plus prefix reuse. Substring matching is monotone, so an
|
|
243
|
+
// untruncated result for "bigod" is a sound superset for "bigodi" and can be
|
|
244
|
+
// re-scored locally while the real request flies.
|
|
245
|
+
|
|
246
|
+
interface CacheEntry {
|
|
247
|
+
scope: string
|
|
248
|
+
folded: string
|
|
249
|
+
at: number
|
|
250
|
+
complete: boolean
|
|
251
|
+
hits: SearchHit[]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const cache: CacheEntry[] = []
|
|
255
|
+
|
|
256
|
+
function scopeKey(targets: SearchTarget[]): string {
|
|
257
|
+
return `${getActiveTenantId() ?? '_'}|${targets.map((t) => t.key).sort().join(',')}`
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function cacheGet(scope: string, folded: string): CacheEntry | undefined {
|
|
261
|
+
const now = Date.now()
|
|
262
|
+
return cache.find((e) => e.scope === scope && e.folded === folded && now - e.at < CACHE_TTL_MS)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cachePrefix(scope: string, folded: string): CacheEntry | undefined {
|
|
266
|
+
const now = Date.now()
|
|
267
|
+
let best: CacheEntry | undefined
|
|
268
|
+
for (const entry of cache) {
|
|
269
|
+
if (entry.scope !== scope || !entry.complete) continue
|
|
270
|
+
if (now - entry.at >= CACHE_TTL_MS) continue
|
|
271
|
+
if (!folded.startsWith(entry.folded)) continue
|
|
272
|
+
if (!best || entry.folded.length > best.folded.length) best = entry
|
|
273
|
+
}
|
|
274
|
+
return best
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function cacheSet(entry: CacheEntry): void {
|
|
278
|
+
const at = cache.findIndex((e) => e.scope === entry.scope && e.folded === entry.folded)
|
|
279
|
+
if (at >= 0) cache.splice(at, 1)
|
|
280
|
+
cache.unshift(entry)
|
|
281
|
+
if (cache.length > CACHE_MAX_ENTRIES) cache.length = CACHE_MAX_ENTRIES
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Drop every cached answer. Call on tenant switch and after writes. */
|
|
285
|
+
export function clearSearchCache(): void {
|
|
286
|
+
cache.length = 0
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Path ① — the server index
|
|
290
|
+
|
|
291
|
+
interface IndexRow {
|
|
292
|
+
entity_key: string
|
|
293
|
+
record_id: string
|
|
294
|
+
title: string | null
|
|
295
|
+
subtitle: string | null
|
|
296
|
+
score: number | null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isMissingFunction(error: { code?: string; message?: string } | null): boolean {
|
|
300
|
+
if (!error) return false
|
|
301
|
+
// PGRST202: PostgREST could not find the function in its schema cache.
|
|
302
|
+
if (error.code === 'PGRST202') return true
|
|
303
|
+
const message = (error.message ?? '').toLowerCase()
|
|
304
|
+
return message.includes(RPC_NAME) && (message.includes('does not exist') || message.includes('not find'))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function searchViaIndex(
|
|
308
|
+
query: NormalizedQuery,
|
|
309
|
+
targets: SearchTarget[],
|
|
310
|
+
perTarget: number,
|
|
311
|
+
limit: number,
|
|
312
|
+
): Promise<{ hits: SearchHit[]; ok: boolean }> {
|
|
313
|
+
const client = getSupabaseClientOptional() as {
|
|
314
|
+
rpc: (fn: string, args: Record<string, unknown>) => Promise<{ data: unknown; error: { code?: string; message?: string } | null }>
|
|
315
|
+
} | null
|
|
316
|
+
const tenantId = getActiveTenantId()
|
|
317
|
+
if (!client || !tenantId) return { hits: [], ok: false }
|
|
318
|
+
|
|
319
|
+
const byKey = new Map(targets.map((t) => [t.key, t]))
|
|
320
|
+
const { data, error } = await client.rpc(RPC_NAME, {
|
|
321
|
+
p_query: query.raw,
|
|
322
|
+
p_tenant_id: tenantId,
|
|
323
|
+
p_entity_keys: targets.map((t) => t.key),
|
|
324
|
+
p_per_source: perTarget * 3,
|
|
325
|
+
p_limit: limit * 3,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
if (error) {
|
|
329
|
+
if (isMissingFunction(error)) {
|
|
330
|
+
indexAvailable = false
|
|
331
|
+
return { hits: [], ok: false }
|
|
332
|
+
}
|
|
333
|
+
throw error
|
|
334
|
+
}
|
|
335
|
+
indexAvailable = true
|
|
336
|
+
|
|
337
|
+
const rows = Array.isArray(data) ? (data as IndexRow[]) : []
|
|
338
|
+
const hits: SearchHit[] = []
|
|
339
|
+
for (const row of rows) {
|
|
340
|
+
const target = byKey.get(row.entity_key)
|
|
341
|
+
if (!target || !row.record_id) continue
|
|
342
|
+
const title = row.title ?? ''
|
|
343
|
+
// Re-score locally so both paths obey one ordering law.
|
|
344
|
+
const local = scoreCandidate(query, {
|
|
345
|
+
title: foldText(title),
|
|
346
|
+
haystack: foldText(`${title} ${row.subtitle ?? ''}`),
|
|
347
|
+
digits: digitsOf(row.subtitle ?? '') + digitsOf(title),
|
|
348
|
+
})
|
|
349
|
+
// The server matched on columns the client never sees (notes, tags). Keep
|
|
350
|
+
// those, ranked under everything the local scorer can explain.
|
|
351
|
+
const score = local > 0 ? local : Math.min(0.55, (row.score ?? 0.4))
|
|
352
|
+
hits.push({
|
|
353
|
+
uid: `${target.key}:${row.record_id}`,
|
|
354
|
+
id: row.record_id,
|
|
355
|
+
key: target.key,
|
|
356
|
+
group: target.label,
|
|
357
|
+
icon: target.icon ?? target.entity.icon,
|
|
358
|
+
title: title || '—',
|
|
359
|
+
subtitle: row.subtitle ?? undefined,
|
|
360
|
+
score: score * (target.boost ?? 1),
|
|
361
|
+
archetype: target.entity.data?.archetype,
|
|
362
|
+
archetypeKind: target.entity.data?.archetypeKind,
|
|
363
|
+
table: target.entity.data?.table,
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
return { hits, ok: true }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Path ② — bounded per-entity scan
|
|
370
|
+
|
|
371
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
372
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
373
|
+
try {
|
|
374
|
+
return await Promise.race([
|
|
375
|
+
promise,
|
|
376
|
+
new Promise<never>((_, reject) => {
|
|
377
|
+
timer = setTimeout(() => reject(new Error('search target timed out')), ms)
|
|
378
|
+
}),
|
|
379
|
+
])
|
|
380
|
+
} finally {
|
|
381
|
+
if (timer) clearTimeout(timer)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function mapLimited<T, R>(
|
|
386
|
+
items: T[],
|
|
387
|
+
limit: number,
|
|
388
|
+
fn: (item: T) => Promise<R>,
|
|
389
|
+
): Promise<Array<{ ok: true; value: R } | { ok: false; item: T }>> {
|
|
390
|
+
const results: Array<{ ok: true; value: R } | { ok: false; item: T }> = new Array(items.length)
|
|
391
|
+
let cursor = 0
|
|
392
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
393
|
+
for (;;) {
|
|
394
|
+
const index = cursor++
|
|
395
|
+
if (index >= items.length) return
|
|
396
|
+
const item = items[index]!
|
|
397
|
+
try {
|
|
398
|
+
results[index] = { ok: true, value: await fn(item) }
|
|
399
|
+
} catch {
|
|
400
|
+
results[index] = { ok: false, item }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
await Promise.all(workers)
|
|
405
|
+
return results
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function searchViaScan(
|
|
409
|
+
query: NormalizedQuery,
|
|
410
|
+
targets: SearchTarget[],
|
|
411
|
+
perTarget: number,
|
|
412
|
+
onProgress?: (hits: SearchHit[]) => void,
|
|
413
|
+
): Promise<{ hits: SearchHit[]; failed: string[] }> {
|
|
414
|
+
// Two rounds, same shape as the SQL function. Round 1 sends the whole phrase
|
|
415
|
+
// (selective, answers the common case). Round 2 sends the longest token —
|
|
416
|
+
// the only form that finds "Maria da Silva" for "maria silva" — and runs
|
|
417
|
+
// only if round 1 found nothing anywhere: an unindexed `%cli%` is expensive,
|
|
418
|
+
// and firing it per miss made multi-word queries 9.8s instead of 0.8s.
|
|
419
|
+
const multiWord = query.tokens.length > 1
|
|
420
|
+
const phrase = query.folded
|
|
421
|
+
const token = query.anchor.length >= MIN_QUERY_LENGTH ? query.anchor : phrase
|
|
422
|
+
const pageSize = Math.min(perTarget * 3, 24)
|
|
423
|
+
const widePageSize = Math.min(perTarget * 6, 40)
|
|
424
|
+
|
|
425
|
+
const searchable = targets.filter((t) => searchableKeys(t.entity).length > 0)
|
|
426
|
+
|
|
427
|
+
// Emit as each target lands, so the box fills instead of spinning until the
|
|
428
|
+
// slowest entity reports.
|
|
429
|
+
const running: SearchHit[] = []
|
|
430
|
+
|
|
431
|
+
const round = (search: string, size: number) =>
|
|
432
|
+
mapLimited(searchable, SCAN_CONCURRENCY, async (target) => {
|
|
433
|
+
const provider = resolveDataProvider(target.entity as EntityDef<{ id: string }>, target.mockData)
|
|
434
|
+
// No count: the aggregate costs more than the rows, times every entity.
|
|
435
|
+
const result = await withTimeout(
|
|
436
|
+
provider.list({ search, pageSize: size, countMode: 'none' }),
|
|
437
|
+
TARGET_TIMEOUT_MS,
|
|
438
|
+
)
|
|
439
|
+
const rows = result.data as unknown as Array<Record<string, unknown>>
|
|
440
|
+
// Post-scoring also guards providers that ignore `search` entirely.
|
|
441
|
+
const hits = rows.map((row) => hitFromRow(target, row, query)).filter((h): h is SearchHit => h !== null)
|
|
442
|
+
if (hits.length && onProgress) {
|
|
443
|
+
running.push(...hits)
|
|
444
|
+
onProgress(running.slice())
|
|
445
|
+
}
|
|
446
|
+
return { target, hits }
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
const collect = (settled: Awaited<ReturnType<typeof round>>) => {
|
|
450
|
+
const hits: SearchHit[] = []
|
|
451
|
+
const failed: string[] = []
|
|
452
|
+
for (const outcome of settled) {
|
|
453
|
+
if (outcome.ok) hits.push(...outcome.value.hits)
|
|
454
|
+
else failed.push(outcome.item.key)
|
|
455
|
+
}
|
|
456
|
+
return { hits, failed }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const first = collect(await round(phrase, pageSize))
|
|
460
|
+
if (first.hits.length > 0 || !multiWord || token === phrase) return first
|
|
461
|
+
|
|
462
|
+
const second = collect(await round(token, widePageSize))
|
|
463
|
+
return { hits: second.hits, failed: [...new Set([...first.failed, ...second.failed])] }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Search every target at once. Never throws for data reasons — a target that
|
|
467
|
+
* errors lands in `failed` and the rest of the answer still ships. */
|
|
468
|
+
export async function searchEverything(
|
|
469
|
+
rawQuery: string,
|
|
470
|
+
options: SearchOptions,
|
|
471
|
+
): Promise<GlobalSearchResult> {
|
|
472
|
+
const started = Date.now()
|
|
473
|
+
const query = normalizeQuery(rawQuery)
|
|
474
|
+
const targets = options.targets
|
|
475
|
+
const limit = options.limit ?? DEFAULT_LIMIT
|
|
476
|
+
const perTarget = options.perTarget ?? DEFAULT_PER_TARGET
|
|
477
|
+
const empty = (via: SearchPath): GlobalSearchResult =>
|
|
478
|
+
assemble(rawQuery, [], targets, { via, failed: [], elapsedMs: Date.now() - started, perTarget, limit })
|
|
479
|
+
|
|
480
|
+
if (query.folded.length < MIN_QUERY_LENGTH || targets.length === 0) return empty('cache')
|
|
481
|
+
|
|
482
|
+
const scope = scopeKey(targets)
|
|
483
|
+
const exact = cacheGet(scope, query.folded)
|
|
484
|
+
if (exact) {
|
|
485
|
+
return assemble(rawQuery, exact.hits, targets, {
|
|
486
|
+
via: 'cache', failed: [], elapsedMs: Date.now() - started, perTarget, limit,
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Paint from the widest complete prefix result while the real one flies.
|
|
491
|
+
if (options.onPartial) {
|
|
492
|
+
const prefix = cachePrefix(scope, query.folded)
|
|
493
|
+
if (prefix) {
|
|
494
|
+
const rescored = prefix.hits
|
|
495
|
+
.map((hit) => {
|
|
496
|
+
const score = scoreCandidate(query, {
|
|
497
|
+
title: foldText(hit.title),
|
|
498
|
+
haystack: foldText(`${hit.title} ${hit.subtitle ?? ''}`),
|
|
499
|
+
digits: digitsOf(hit.subtitle ?? ''),
|
|
500
|
+
})
|
|
501
|
+
return score > 0 ? { ...hit, score } : null
|
|
502
|
+
})
|
|
503
|
+
.filter((h): h is SearchHit => h !== null)
|
|
504
|
+
options.onPartial(assemble(rawQuery, rescored, targets, {
|
|
505
|
+
via: 'cache', failed: [], elapsedMs: Date.now() - started, perTarget, limit, partial: true,
|
|
506
|
+
}))
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let hits: SearchHit[] = []
|
|
511
|
+
let failed: string[] = []
|
|
512
|
+
let via: SearchPath = 'scan'
|
|
513
|
+
|
|
514
|
+
if (!options.forceScan && indexAvailable !== false) {
|
|
515
|
+
try {
|
|
516
|
+
const indexed = await searchViaIndex(query, targets, perTarget, limit)
|
|
517
|
+
if (indexed.ok) {
|
|
518
|
+
hits = indexed.hits
|
|
519
|
+
via = 'index'
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
// One bad query is no reason to abandon a live index — scan just this once.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (via !== 'index') {
|
|
527
|
+
const emit = options.onPartial
|
|
528
|
+
const scanned = await searchViaScan(query, targets, perTarget, emit
|
|
529
|
+
? (running) => emit(assemble(rawQuery, running, targets, {
|
|
530
|
+
via: 'scan', failed: [], elapsedMs: Date.now() - started, perTarget, limit, partial: true,
|
|
531
|
+
}))
|
|
532
|
+
: undefined)
|
|
533
|
+
hits = scanned.hits
|
|
534
|
+
failed = scanned.failed
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (options.signal?.aborted) return empty(via)
|
|
538
|
+
|
|
539
|
+
const result = assemble(rawQuery, hits, targets, {
|
|
540
|
+
via, failed, elapsedMs: Date.now() - started, perTarget, limit,
|
|
541
|
+
})
|
|
542
|
+
cacheSet({
|
|
543
|
+
scope,
|
|
544
|
+
folded: query.folded,
|
|
545
|
+
at: Date.now(),
|
|
546
|
+
// Only an uncapped answer is a sound superset for longer queries.
|
|
547
|
+
complete: failed.length === 0 && !result.groups.some((g) => g.hasMore),
|
|
548
|
+
hits,
|
|
549
|
+
})
|
|
550
|
+
return result
|
|
551
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
searchEverything,
|
|
3
|
+
clearSearchCache,
|
|
4
|
+
setSearchIndexAvailable,
|
|
5
|
+
isSearchIndexAvailable,
|
|
6
|
+
MIN_QUERY_LENGTH,
|
|
7
|
+
} from './engine'
|
|
8
|
+
export {
|
|
9
|
+
foldText,
|
|
10
|
+
foldWithMap,
|
|
11
|
+
digitsOf,
|
|
12
|
+
normalizeQuery,
|
|
13
|
+
similarity,
|
|
14
|
+
scoreCandidate,
|
|
15
|
+
highlightRanges,
|
|
16
|
+
} from './text'
|
|
17
|
+
export type { NormalizedQuery, RankCandidate } from './text'
|
|
18
|
+
export type {
|
|
19
|
+
SearchTarget,
|
|
20
|
+
SearchHit,
|
|
21
|
+
SearchGroup,
|
|
22
|
+
SearchPath,
|
|
23
|
+
SearchOptions,
|
|
24
|
+
GlobalSearchResult,
|
|
25
|
+
} from './types'
|