@fayz-ai/core 0.9.0 → 0.11.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 (218) hide show
  1. package/README.md +2 -2
  2. package/dist/access/index.js.map +1 -1
  3. package/dist/chunk-BEGXYHS4.js +566 -0
  4. package/dist/chunk-BEGXYHS4.js.map +1 -0
  5. package/dist/chunk-ED6NBH6U.js +348 -0
  6. package/dist/chunk-ED6NBH6U.js.map +1 -0
  7. package/dist/chunk-EWKX4VUM.js +429 -0
  8. package/dist/chunk-EWKX4VUM.js.map +1 -0
  9. package/dist/chunk-LARZWDHA.js +319 -0
  10. package/dist/chunk-LARZWDHA.js.map +1 -0
  11. package/dist/chunk-OXQGRJZT.js +125 -0
  12. package/dist/chunk-OXQGRJZT.js.map +1 -0
  13. package/dist/chunk-RRYFXUQX.js +1247 -0
  14. package/dist/chunk-RRYFXUQX.js.map +1 -0
  15. package/dist/{chunk-5RJVUC6J.js → chunk-TDI6F6PT.js} +373 -25
  16. package/dist/chunk-TDI6F6PT.js.map +1 -0
  17. package/dist/{chunk-V2MSAUHO.js → chunk-UVKH6RDX.js} +211 -335
  18. package/dist/chunk-UVKH6RDX.js.map +1 -0
  19. package/dist/chunk-V53GR4UT.js +289 -0
  20. package/dist/chunk-V53GR4UT.js.map +1 -0
  21. package/dist/connector-runtime/index.d.ts +15 -0
  22. package/dist/connector-runtime/index.d.ts.map +1 -0
  23. package/dist/connector-runtime/index.js +3 -0
  24. package/dist/connector-runtime/index.js.map +1 -0
  25. package/dist/data/archetype.d.ts.map +1 -1
  26. package/dist/data/backend-env.d.ts +38 -0
  27. package/dist/data/backend-env.d.ts.map +1 -0
  28. package/dist/data/bulk.d.ts +24 -0
  29. package/dist/data/bulk.d.ts.map +1 -1
  30. package/dist/data/cached.d.ts.map +1 -1
  31. package/dist/data/custom-fields.d.ts +5 -0
  32. package/dist/data/custom-fields.d.ts.map +1 -0
  33. package/dist/data/index.d.ts +2 -1
  34. package/dist/data/index.d.ts.map +1 -1
  35. package/dist/data/index.js +3 -1
  36. package/dist/data/mock.d.ts.map +1 -1
  37. package/dist/data/platform-api.d.ts.map +1 -1
  38. package/dist/data/resolve.d.ts.map +1 -1
  39. package/dist/data/supabase.d.ts.map +1 -1
  40. package/dist/data/types.d.ts +8 -0
  41. package/dist/data/types.d.ts.map +1 -1
  42. package/dist/entity/events.d.ts +14 -0
  43. package/dist/entity/events.d.ts.map +1 -0
  44. package/dist/events/envelope.d.ts +31 -0
  45. package/dist/events/envelope.d.ts.map +1 -0
  46. package/dist/events/index.d.ts +4 -0
  47. package/dist/events/index.d.ts.map +1 -1
  48. package/dist/events/log.d.ts +44 -0
  49. package/dist/events/log.d.ts.map +1 -0
  50. package/dist/events/types.d.ts +40 -0
  51. package/dist/events/types.d.ts.map +1 -0
  52. package/dist/fields/store.d.ts +45 -0
  53. package/dist/fields/store.d.ts.map +1 -0
  54. package/dist/i18n/index.d.ts +10 -0
  55. package/dist/i18n/index.d.ts.map +1 -1
  56. package/dist/i18n/index.js +1 -1
  57. package/dist/i18n/shell-translations.en.d.ts.map +1 -1
  58. package/dist/i18n/shell-translations.pt-br.d.ts.map +1 -1
  59. package/dist/index.d.ts +45 -21
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +543 -56
  62. package/dist/index.js.map +1 -1
  63. package/dist/integrations/events.d.ts +7 -0
  64. package/dist/integrations/events.d.ts.map +1 -0
  65. package/dist/integrations/index.d.ts +533 -29
  66. package/dist/integrations/index.d.ts.map +1 -1
  67. package/dist/integrations/index.js +3 -1
  68. package/dist/integrations/permissions.d.ts +132 -0
  69. package/dist/integrations/permissions.d.ts.map +1 -0
  70. package/dist/integrations/request.d.ts +170 -0
  71. package/dist/integrations/request.d.ts.map +1 -0
  72. package/dist/integrations/runtime-token.d.ts +33 -0
  73. package/dist/integrations/runtime-token.d.ts.map +1 -0
  74. package/dist/integrations/runtime.d.ts +398 -0
  75. package/dist/integrations/runtime.d.ts.map +1 -0
  76. package/dist/integrations/settings.d.ts +102 -0
  77. package/dist/integrations/settings.d.ts.map +1 -0
  78. package/dist/integrations/store.d.ts +145 -0
  79. package/dist/integrations/store.d.ts.map +1 -0
  80. package/dist/{address/index.d.ts → lib/address.d.ts} +1 -1
  81. package/dist/{address/index.d.ts.map → lib/address.d.ts.map} +1 -1
  82. package/dist/{phone/index.d.ts → lib/phone.d.ts} +1 -1
  83. package/dist/lib/phone.d.ts.map +1 -0
  84. package/dist/{seo/index.d.ts → lib/seo.d.ts} +18 -1
  85. package/dist/lib/seo.d.ts.map +1 -0
  86. package/dist/{tenant/index.d.ts → lib/tenant.d.ts} +1 -1
  87. package/dist/lib/tenant.d.ts.map +1 -0
  88. package/dist/manifest/index.d.ts +1 -1
  89. package/dist/manifest/index.d.ts.map +1 -1
  90. package/dist/messaging/index.d.ts +3 -0
  91. package/dist/messaging/index.d.ts.map +1 -0
  92. package/dist/messaging/registry.d.ts +35 -0
  93. package/dist/messaging/registry.d.ts.map +1 -0
  94. package/dist/messaging/types.d.ts +67 -0
  95. package/dist/messaging/types.d.ts.map +1 -0
  96. package/dist/plugin/index.js +3 -1
  97. package/dist/plugin/runtime.d.ts +17 -0
  98. package/dist/plugin/runtime.d.ts.map +1 -1
  99. package/dist/registry/block-catalog.d.ts +78 -0
  100. package/dist/registry/block-catalog.d.ts.map +1 -0
  101. package/dist/registry/block-constraints.d.ts +64 -0
  102. package/dist/registry/block-constraints.d.ts.map +1 -0
  103. package/dist/registry/block-schema.d.ts +69 -0
  104. package/dist/registry/block-schema.d.ts.map +1 -0
  105. package/dist/registry/blocks.d.ts +84 -0
  106. package/dist/registry/blocks.d.ts.map +1 -0
  107. package/dist/registry/entities.d.ts +179 -0
  108. package/dist/registry/entities.d.ts.map +1 -0
  109. package/dist/registry/extension-points.d.ts +117 -0
  110. package/dist/registry/extension-points.d.ts.map +1 -0
  111. package/dist/registry/handles.d.ts +41 -0
  112. package/dist/registry/handles.d.ts.map +1 -0
  113. package/dist/registry/index.d.ts +41 -41
  114. package/dist/registry/index.d.ts.map +1 -1
  115. package/dist/registry/loaders.d.ts +55 -0
  116. package/dist/registry/loaders.d.ts.map +1 -0
  117. package/dist/registry/registry-class.d.ts +157 -0
  118. package/dist/registry/registry-class.d.ts.map +1 -0
  119. package/dist/registry/schema-check.d.ts +14 -0
  120. package/dist/registry/schema-check.d.ts.map +1 -0
  121. package/dist/router/index.d.ts +78 -1
  122. package/dist/router/index.d.ts.map +1 -1
  123. package/dist/router/index.js +1 -1
  124. package/dist/{shell-translations.pt-br-2GWRQRRP.js → shell-translations.pt-br-BM5ZFSKJ.js} +72 -3
  125. package/dist/shell-translations.pt-br-BM5ZFSKJ.js.map +1 -0
  126. package/dist/telemetry/errors.d.ts +87 -0
  127. package/dist/telemetry/errors.d.ts.map +1 -0
  128. package/dist/testing/index.js +4 -1
  129. package/dist/{testing/index.d.ts → testing.d.ts} +5 -3
  130. package/dist/testing.d.ts.map +1 -0
  131. package/dist/types/analytics.d.ts +213 -0
  132. package/dist/types/analytics.d.ts.map +1 -0
  133. package/dist/types/crud.d.ts +37 -3
  134. package/dist/types/crud.d.ts.map +1 -1
  135. package/dist/types/plugins.d.ts +138 -1
  136. package/dist/types/plugins.d.ts.map +1 -1
  137. package/package.json +9 -17
  138. package/dist/blocks/index.d.ts +0 -25
  139. package/dist/blocks/index.d.ts.map +0 -1
  140. package/dist/chunk-5RJVUC6J.js.map +0 -1
  141. package/dist/chunk-L4TLMYGC.js +0 -42
  142. package/dist/chunk-L4TLMYGC.js.map +0 -1
  143. package/dist/chunk-V2MSAUHO.js.map +0 -1
  144. package/dist/chunk-X5THHG6T.js +0 -77
  145. package/dist/chunk-X5THHG6T.js.map +0 -1
  146. package/dist/chunk-Y2C2FBQP.js +0 -35
  147. package/dist/chunk-Y2C2FBQP.js.map +0 -1
  148. package/dist/chunk-YH25Y4FW.js +0 -311
  149. package/dist/chunk-YH25Y4FW.js.map +0 -1
  150. package/dist/phone/index.d.ts.map +0 -1
  151. package/dist/seo/index.d.ts.map +0 -1
  152. package/dist/shell-translations.pt-br-2GWRQRRP.js.map +0 -1
  153. package/dist/tenant/index.d.ts.map +0 -1
  154. package/dist/testing/index.d.ts.map +0 -1
  155. package/src/access/denial.ts +0 -33
  156. package/src/access/engine.test.ts +0 -48
  157. package/src/access/engine.ts +0 -136
  158. package/src/access/index.ts +0 -14
  159. package/src/access/limits.ts +0 -18
  160. package/src/address/index.ts +0 -188
  161. package/src/app/render.tsx +0 -69
  162. package/src/blocks/index.tsx +0 -68
  163. package/src/data/archetype.ts +0 -255
  164. package/src/data/bulk.ts +0 -35
  165. package/src/data/cached.ts +0 -55
  166. package/src/data/count.test.ts +0 -152
  167. package/src/data/count.ts +0 -131
  168. package/src/data/filters.test.ts +0 -70
  169. package/src/data/filters.ts +0 -90
  170. package/src/data/index.ts +0 -11
  171. package/src/data/mock.ts +0 -90
  172. package/src/data/platform-api.ts +0 -209
  173. package/src/data/refresh.ts +0 -115
  174. package/src/data/resolve.ts +0 -126
  175. package/src/data/supabase.ts +0 -230
  176. package/src/data/types.ts +0 -50
  177. package/src/entity/index.ts +0 -2
  178. package/src/entity/registry.ts +0 -49
  179. package/src/events/index.tsx +0 -82
  180. package/src/i18n/index.ts +0 -476
  181. package/src/i18n/shell-translations.en.ts +0 -875
  182. package/src/i18n/shell-translations.pt-br.ts +0 -873
  183. package/src/index.ts +0 -217
  184. package/src/integrations/index.ts +0 -237
  185. package/src/lib/cache.ts +0 -118
  186. package/src/lib/csv.ts +0 -54
  187. package/src/lib/format.ts +0 -58
  188. package/src/manifest/app-manifest.schema.json +0 -676
  189. package/src/manifest/index.ts +0 -518
  190. package/src/manifest/serialize.ts +0 -52
  191. package/src/phone/index.ts +0 -85
  192. package/src/plugin/createSafeDataProvider.ts +0 -30
  193. package/src/plugin/index.ts +0 -1
  194. package/src/plugin/runtime.ts +0 -331
  195. package/src/registry/index.ts +0 -163
  196. package/src/router/history.ts +0 -86
  197. package/src/router/index.ts +0 -29
  198. package/src/runtime/index.ts +0 -15
  199. package/src/runtime/oauth.ts +0 -219
  200. package/src/search/engine.ts +0 -559
  201. package/src/search/index.ts +0 -25
  202. package/src/search/text.ts +0 -206
  203. package/src/search/types.ts +0 -75
  204. package/src/seo/index.ts +0 -128
  205. package/src/tenant/index.ts +0 -38
  206. package/src/testing/index.ts +0 -116
  207. package/src/types/auth.ts +0 -41
  208. package/src/types/billing.ts +0 -39
  209. package/src/types/crud.ts +0 -184
  210. package/src/types/entities.ts +0 -142
  211. package/src/types/entitlements.ts +0 -45
  212. package/src/types/i18n.ts +0 -7
  213. package/src/types/index.ts +0 -11
  214. package/src/types/org.ts +0 -137
  215. package/src/types/payment.ts +0 -44
  216. package/src/types/permissions.ts +0 -32
  217. package/src/types/plugins.ts +0 -480
  218. 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'