@fayz-ai/core 0.9.0 → 0.10.0

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