@fayz-ai/core 0.8.2 → 0.8.3

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.
Files changed (54) hide show
  1. package/dist/{chunk-SO3VUKGZ.cjs → chunk-CATASHPK.cjs} +117 -4
  2. package/dist/chunk-CATASHPK.cjs.map +1 -0
  3. package/dist/{chunk-JHGXROKV.js → chunk-DZALZ6Q6.js} +117 -4
  4. package/dist/chunk-DZALZ6Q6.js.map +1 -0
  5. package/dist/{chunk-CYAWDW44.cjs → chunk-KSI2L3E4.cjs} +6 -4
  6. package/dist/chunk-KSI2L3E4.cjs.map +1 -0
  7. package/dist/{chunk-XJKW2JRW.js → chunk-TYPVSWLG.js} +6 -4
  8. package/dist/chunk-TYPVSWLG.js.map +1 -0
  9. package/dist/data/archetype.d.ts.map +1 -1
  10. package/dist/data/index.cjs +12 -12
  11. package/dist/data/index.js +1 -1
  12. package/dist/data/refresh.d.ts +36 -0
  13. package/dist/data/refresh.d.ts.map +1 -0
  14. package/dist/data/supabase.d.ts.map +1 -1
  15. package/dist/data/types.d.ts +6 -0
  16. package/dist/data/types.d.ts.map +1 -1
  17. package/dist/i18n/index.cjs +10 -10
  18. package/dist/i18n/index.js +1 -1
  19. package/dist/i18n/shell-translations.d.ts.map +1 -1
  20. package/dist/index.cjs +697 -88
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +652 -59
  25. package/dist/index.js.map +1 -1
  26. package/dist/search/engine.d.ts +12 -0
  27. package/dist/search/engine.d.ts.map +1 -0
  28. package/dist/search/index.d.ts +5 -0
  29. package/dist/search/index.d.ts.map +1 -0
  30. package/dist/search/text.d.ts +37 -0
  31. package/dist/search/text.d.ts.map +1 -0
  32. package/dist/search/types.d.ts +72 -0
  33. package/dist/search/types.d.ts.map +1 -0
  34. package/dist/types/org.d.ts +28 -0
  35. package/dist/types/org.d.ts.map +1 -1
  36. package/dist/types/plugins.d.ts +4 -0
  37. package/dist/types/plugins.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/data/archetype.ts +4 -2
  40. package/src/data/refresh.ts +105 -0
  41. package/src/data/supabase.ts +3 -1
  42. package/src/data/types.ts +6 -0
  43. package/src/i18n/shell-translations.ts +115 -2
  44. package/src/index.ts +19 -1
  45. package/src/search/engine.ts +551 -0
  46. package/src/search/index.ts +25 -0
  47. package/src/search/text.ts +206 -0
  48. package/src/search/types.ts +75 -0
  49. package/src/types/org.ts +29 -0
  50. package/src/types/plugins.ts +4 -0
  51. package/dist/chunk-CYAWDW44.cjs.map +0 -1
  52. package/dist/chunk-JHGXROKV.js.map +0 -1
  53. package/dist/chunk-SO3VUKGZ.cjs.map +0 -1
  54. package/dist/chunk-XJKW2JRW.js.map +0 -1
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ export type { EntityDef, EntityImageConfig, FieldDef, FieldType, FieldRelation,
4
4
  export type { PluginManifest, PluginScope, PluginStatus, ResolvedPluginManifest, PluginRuntime, PluginRuntimeContext, PluginWidgetZone, PluginWidgetDefinition, PluginNavigationEntry, PluginSettingsTab, PluginRouteDefinition, PluginAITool, AIToolMode, AIToolExecution, AgentRpcDeclaration, AIToolParameterProperty, AIToolParameters, AIToolSuggestion, PluginRegistryDef, PluginMigration, PluginDiagnostic, VerticalId, ScaffoldType, TenantPluginBinding, DashboardWidgetDef, DashboardWidgetKind, DashboardSurface, DashboardLayoutConfig, ResolvedDashboardWidget } from './types/plugins'
5
5
  export { WidgetZone } from './types/plugins'
6
6
  export type { AuthAdapter, AuthUser, AuthSession, AuthProvider } from './types/auth'
7
- export type { OrgAdapter, Organization, OrgMember, OrgMembership, Location, Invite, CreateOrgOptions } from './types/org'
7
+ export type { OrgAdapter, Organization, OrgMember, OrgMembership, TeamPerson, Location, Invite, CreateOrgOptions } from './types/org'
8
8
  export type { PermissionsConfig, FeatureDeclaration, PermissionAction, PermissionProfile, SystemPermission } from './types/permissions'
9
9
  export type { Plan, BillingConfig, BillingCheckoutFn } from './types/billing'
10
10
  export type { PlanEntitlements, LimitDeclaration } from './types/entitlements'
@@ -21,6 +21,13 @@ export { createMockProvider } from './data/mock'
21
21
  export { createArchetypeProvider } from './data/archetype'
22
22
  export { withCache } from './data/cached'
23
23
  export { resolveDataProvider } from './data/resolve'
24
+ export {
25
+ DATA_CHANGED_EVENT,
26
+ emitDataChanged,
27
+ matchesDataChange,
28
+ useDataChanged,
29
+ } from './data/refresh'
30
+ export type { DataChangedPayload, DataChangedMatch } from './data/refresh'
24
31
  export { createSafeDataProvider } from './plugin/createSafeDataProvider'
25
32
  export {
26
33
  COUNTRIES, DEFAULT_COUNTRY, getCountry, unmaskPhone, maskPhone, maskDigitCount,
@@ -96,6 +103,17 @@ export type { EventBus, EventHandler } from './events'
96
103
  export { registerEntity, getEntityByKey, getAllEntities, clearEntityRegistry, deriveEntityKey } from './entity/registry'
97
104
  export type { RegisteredEntity } from './entity/registry'
98
105
 
106
+ // Global search (command palette + agent lookups share one engine)
107
+ export {
108
+ searchEverything, clearSearchCache, setSearchIndexAvailable, isSearchIndexAvailable,
109
+ MIN_QUERY_LENGTH, foldText, foldWithMap, digitsOf, normalizeQuery, similarity,
110
+ scoreCandidate, highlightRanges,
111
+ } from './search'
112
+ export type {
113
+ SearchTarget, SearchHit, SearchGroup, SearchPath, SearchOptions, GlobalSearchResult,
114
+ NormalizedQuery, RankCandidate,
115
+ } from './search'
116
+
99
117
  // Uniform registry (component / block / page / metric / scaffold / plugin)
100
118
  export {
101
119
  Registry,
@@ -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'