@open-mercato/core 0.6.7-develop.6696.1.4236709f7e → 0.6.7-develop.6725.1.07a748cfc2

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 (154) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/admin/nav.js +14 -4
  3. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +8 -19
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/frontend/login.js +4 -1
  7. package/dist/modules/auth/frontend/login.js.map +2 -2
  8. package/dist/modules/auth/lib/backendChrome.js +31 -14
  9. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  10. package/dist/modules/customers/api/companies/[id]/route.js +3 -4
  11. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  12. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -2
  13. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  14. package/dist/modules/customers/api/utils.js +12 -18
  15. package/dist/modules/customers/api/utils.js.map +2 -2
  16. package/dist/modules/customers/cli.js +11 -4
  17. package/dist/modules/customers/cli.js.map +2 -2
  18. package/dist/modules/customers/components/detail/ActiveDealCard.js +2 -1
  19. package/dist/modules/customers/components/detail/ActiveDealCard.js.map +2 -2
  20. package/dist/modules/customers/components/detail/CompanyKpiBar.js +6 -5
  21. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
  23. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  24. package/dist/modules/customers/components/detail/dashboard/helpers.js +4 -3
  25. package/dist/modules/customers/components/detail/dashboard/helpers.js.map +2 -2
  26. package/dist/modules/customers/components/formConfig.js +14 -10
  27. package/dist/modules/customers/components/formConfig.js.map +2 -2
  28. package/dist/modules/customers/lib/dealStatus.js +38 -0
  29. package/dist/modules/customers/lib/dealStatus.js.map +7 -0
  30. package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
  31. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  32. package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
  33. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  34. package/dist/modules/dashboards/lib/aggregations.js +76 -40
  35. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  36. package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
  37. package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
  38. package/dist/modules/dashboards/services/widgetDataService.js +228 -5
  39. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  40. package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
  41. package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
  42. package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
  43. package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
  44. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
  45. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
  46. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
  47. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
  48. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
  49. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
  50. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
  51. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
  52. package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
  53. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  54. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
  55. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
  56. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
  57. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  58. package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
  59. package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
  60. package/dist/modules/messages/lib/searchLookup.js +8 -16
  61. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/sessions/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/di.js +17 -1
  65. package/dist/modules/payment_gateways/di.js.map +2 -2
  66. package/dist/modules/payment_gateways/lib/gateway-service.js +8 -0
  67. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  68. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js +58 -0
  69. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js.map +7 -0
  70. package/dist/modules/query_index/api/openapi.js +13 -1
  71. package/dist/modules/query_index/api/openapi.js.map +2 -2
  72. package/dist/modules/query_index/api/status.js +165 -105
  73. package/dist/modules/query_index/api/status.js.map +2 -2
  74. package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
  75. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  76. package/dist/modules/query_index/lib/batch.js +11 -4
  77. package/dist/modules/query_index/lib/batch.js.map +2 -2
  78. package/dist/modules/query_index/lib/document.js +12 -5
  79. package/dist/modules/query_index/lib/document.js.map +2 -2
  80. package/dist/modules/query_index/lib/engine.js +36 -4
  81. package/dist/modules/query_index/lib/engine.js.map +2 -2
  82. package/dist/modules/query_index/lib/indexer.js +1 -1
  83. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  84. package/dist/modules/query_index/lib/search-tokens.js +7 -4
  85. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  86. package/dist/modules/sales/api/documents/factory.js +14 -3
  87. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  88. package/dist/modules/sales/commands/documents.js +22 -1
  89. package/dist/modules/sales/commands/documents.js.map +2 -2
  90. package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
  91. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
  92. package/dist/modules/sales/data/validators.js +25 -4
  93. package/dist/modules/sales/data/validators.js.map +2 -2
  94. package/dist/modules/sales/di.js +7 -0
  95. package/dist/modules/sales/di.js.map +2 -2
  96. package/dist/modules/sales/services/paymentOrderTotalResolver.js +52 -0
  97. package/dist/modules/sales/services/paymentOrderTotalResolver.js.map +7 -0
  98. package/package.json +7 -7
  99. package/src/modules/auth/api/admin/nav.ts +24 -4
  100. package/src/modules/auth/api/users/route.ts +8 -28
  101. package/src/modules/auth/frontend/login.tsx +4 -1
  102. package/src/modules/auth/lib/backendChrome.tsx +51 -13
  103. package/src/modules/customers/api/companies/[id]/route.ts +3 -4
  104. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -2
  105. package/src/modules/customers/api/utils.ts +12 -31
  106. package/src/modules/customers/cli.ts +11 -4
  107. package/src/modules/customers/components/detail/ActiveDealCard.tsx +2 -1
  108. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +6 -5
  109. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
  110. package/src/modules/customers/components/detail/dashboard/helpers.ts +4 -3
  111. package/src/modules/customers/components/formConfig.tsx +12 -8
  112. package/src/modules/customers/lib/dealStatus.ts +48 -0
  113. package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
  114. package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
  115. package/src/modules/dashboards/lib/aggregations.ts +122 -43
  116. package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
  117. package/src/modules/dashboards/services/widgetDataService.ts +342 -4
  118. package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
  119. package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
  120. package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
  121. package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
  122. package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
  123. package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
  124. package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
  125. package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
  126. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
  127. package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
  128. package/src/modules/messages/lib/searchLookup.ts +11 -34
  129. package/src/modules/payment_gateways/api/sessions/route.ts +5 -0
  130. package/src/modules/payment_gateways/di.ts +28 -2
  131. package/src/modules/payment_gateways/i18n/de.json +3 -0
  132. package/src/modules/payment_gateways/i18n/en.json +3 -0
  133. package/src/modules/payment_gateways/i18n/es.json +3 -0
  134. package/src/modules/payment_gateways/i18n/pl.json +3 -0
  135. package/src/modules/payment_gateways/lib/gateway-service.ts +14 -0
  136. package/src/modules/payment_gateways/lib/order-amount-reconciliation.ts +93 -0
  137. package/src/modules/query_index/api/openapi.ts +12 -0
  138. package/src/modules/query_index/api/status.ts +238 -136
  139. package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
  140. package/src/modules/query_index/i18n/de.json +5 -3
  141. package/src/modules/query_index/i18n/en.json +5 -3
  142. package/src/modules/query_index/i18n/es.json +5 -3
  143. package/src/modules/query_index/i18n/pl.json +5 -3
  144. package/src/modules/query_index/lib/batch.ts +11 -4
  145. package/src/modules/query_index/lib/document.ts +35 -4
  146. package/src/modules/query_index/lib/engine.ts +53 -5
  147. package/src/modules/query_index/lib/indexer.ts +1 -1
  148. package/src/modules/query_index/lib/search-tokens.ts +13 -4
  149. package/src/modules/sales/api/documents/factory.ts +28 -3
  150. package/src/modules/sales/commands/documents.ts +26 -2
  151. package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
  152. package/src/modules/sales/data/validators.ts +50 -5
  153. package/src/modules/sales/di.ts +9 -0
  154. package/src/modules/sales/services/paymentOrderTotalResolver.ts +67 -0
@@ -11,7 +11,13 @@ import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
11
11
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
12
  import { queryIndexTag, queryIndexErrorSchema, queryIndexStatusResponseSchema } from './openapi'
13
13
  import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
14
+ import {
15
+ envDisablesAutoIndexing,
16
+ SEARCH_AUTO_INDEX_CONFIG_KEY,
17
+ SEARCH_AUTO_INDEX_CONFIG_MODULE,
18
+ } from '@open-mercato/shared/lib/search/auto-indexing'
14
19
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
20
+ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
15
21
 
16
22
  export const metadata = {
17
23
  GET: { requireAuth: true, requireFeatures: ['query_index.status.view'] },
@@ -97,10 +103,9 @@ export async function GET(req: Request) {
97
103
  const byId = new Map<string, { entityId: string; label: string }>()
98
104
  for (const g of generated) byId.set(g.entityId, g)
99
105
 
100
- let entityIds = generatedIds.slice()
101
-
102
- // Resolve search module configs to determine vector-enabled entities
103
- // Entities with buildSource defined are vector-search enabled
106
+ // Resolve search module configs to determine which entities each search backend covers.
107
+ // Entities with buildSource defined are vector-search capable; entities with a fieldPolicy
108
+ // are fulltext-capable.
104
109
  let searchModuleConfigs: SearchModuleConfig[] = []
105
110
  try {
106
111
  searchModuleConfigs = container.resolve('searchModuleConfigs') as SearchModuleConfig[]
@@ -108,34 +113,67 @@ export async function GET(req: Request) {
108
113
  // Search module configs not available
109
114
  }
110
115
 
111
- const vectorEnabledEntities = new Set<string>()
116
+ const vectorConfiguredEntities = new Set<string>()
112
117
  const fulltextEnabledEntities = new Set<string>()
118
+ const searchConfiguredEntities = new Set<string>()
113
119
  for (const moduleConfig of searchModuleConfigs) {
114
120
  for (const entity of moduleConfig.entities ?? []) {
115
121
  if (entity.enabled !== false) {
116
122
  // Vector: entities with buildSource defined
117
123
  if (typeof entity.buildSource === 'function') {
118
- vectorEnabledEntities.add(entity.entityId)
124
+ vectorConfiguredEntities.add(entity.entityId)
125
+ searchConfiguredEntities.add(entity.entityId)
119
126
  }
120
127
  // Fulltext: entities with fieldPolicy defined
121
128
  if (entity.fieldPolicy && typeof entity.fieldPolicy === 'object') {
122
129
  fulltextEnabledEntities.add(entity.entityId)
130
+ searchConfiguredEntities.add(entity.entityId)
123
131
  }
124
132
  }
125
133
  }
126
134
  }
127
135
 
128
- // Resolve fulltext strategy for entity counts
129
- let fulltextStrategy: FullTextSearchStrategy | null = null
136
+ let searchStrategies: unknown[] = []
130
137
  try {
131
- const searchStrategies = container.resolve('searchStrategies') as unknown[]
132
- fulltextStrategy = (searchStrategies?.find(
133
- (s: unknown) => (s as { id?: string })?.id === 'fulltext',
134
- ) as FullTextSearchStrategy) ?? null
138
+ searchStrategies = (container.resolve('searchStrategies') as unknown[]) ?? []
135
139
  } catch {
136
- fulltextStrategy = null
140
+ searchStrategies = []
137
141
  }
138
142
 
143
+ // Resolve fulltext strategy for entity counts
144
+ const fulltextStrategy = (searchStrategies.find(
145
+ (s: unknown) => (s as { id?: string })?.id === 'fulltext',
146
+ ) as FullTextSearchStrategy) ?? null
147
+
148
+ // Vector coverage is only a meaningful signal when embeddings can actually be written:
149
+ // the instance has not switched auto-indexing off, an embedding provider is reachable,
150
+ // and the tenant has not opted out. Reporting a permanent `0 / n` gap on installs with
151
+ // no embedding provider trains operators to ignore this page.
152
+ const vectorRuntimeEnabled = await (async () => {
153
+ if (vectorConfiguredEntities.size === 0) return false
154
+ if (envDisablesAutoIndexing()) return false
155
+ const vectorStrategy = searchStrategies.find(
156
+ (s: unknown) => (s as { id?: string })?.id === 'vector',
157
+ ) as { isAvailable?: () => Promise<boolean> } | undefined
158
+ if (typeof vectorStrategy?.isAvailable !== 'function') return false
159
+ try {
160
+ if (!(await vectorStrategy.isAvailable())) return false
161
+ } catch {
162
+ return false
163
+ }
164
+ try {
165
+ const moduleConfigService = container.resolve('moduleConfigService') as ModuleConfigService
166
+ const value = await moduleConfigService.getValue<boolean>(
167
+ SEARCH_AUTO_INDEX_CONFIG_MODULE,
168
+ SEARCH_AUTO_INDEX_CONFIG_KEY,
169
+ { defaultValue: true, scope: { tenantId } },
170
+ )
171
+ return value !== false
172
+ } catch {
173
+ return true
174
+ }
175
+ })()
176
+
139
177
  // Fetch fulltext entity counts
140
178
  let fulltextEntityCounts: Record<string, number> | null = null
141
179
  if (fulltextStrategy) {
@@ -146,7 +184,9 @@ export async function GET(req: Request) {
146
184
  }
147
185
  }
148
186
 
149
- // Limit to entities that have active custom field definitions in current scope
187
+ // Entities with active custom field definitions in the current scope. This is reported
188
+ // per row so the client can filter on it — it is NOT a gate on which entities are listed.
189
+ const customFieldEntities = new Set<string>()
150
190
  try {
151
191
  let cfQuery = db
152
192
  .selectFrom('custom_field_defs' as any)
@@ -168,20 +208,30 @@ export async function GET(req: Request) {
168
208
  ]))
169
209
  }
170
210
  const cfRows = await cfQuery.execute() as Array<{ entity_id: string }>
171
- const enabled = new Set<string>((cfRows || []).map((r) => String(r.entity_id)))
172
- entityIds = entityIds.filter((id) => enabled.has(id))
211
+ for (const row of cfRows || []) customFieldEntities.add(String(row.entity_id))
173
212
  } catch {}
174
213
 
175
214
  const HEARTBEAT_STALE_MS = 60_000
176
215
  const COVERAGE_STALE_MS = 60_000
177
216
  const COVERAGE_REFRESH_CONCURRENCY = 8
178
217
 
179
- async function fetchJobSummary(entityType: string, tenantIdParam: string | null, organizationIdParam: string | null) {
218
+ const idleJobSummary = () => ({ status: 'idle' as const, partitions: [] as any[] })
219
+
220
+ // Job rows for every listed entity are fetched in a single query. This endpoint is polled
221
+ // every few seconds and the entity list is no longer capped at custom-field entities, so a
222
+ // per-entity round trip here would scale the poll cost with the number of indexed entities.
223
+ async function fetchJobSummaries(
224
+ entityTypes: string[],
225
+ tenantIdParam: string | null,
226
+ organizationIdParam: string | null,
227
+ ): Promise<Map<string, ReturnType<typeof buildJobSummary>>> {
228
+ const byEntity = new Map<string, ReturnType<typeof buildJobSummary>>()
229
+ if (!entityTypes.length) return byEntity
180
230
  try {
181
231
  let jobQuery = db
182
232
  .selectFrom('entity_index_jobs' as any)
183
233
  .selectAll()
184
- .where('entity_type' as any, '=', entityType)
234
+ .where('entity_type' as any, 'in', entityTypes)
185
235
  .where(sql<boolean>`tenant_id is not distinct from ${tenantIdParam ?? null}`)
186
236
  if (organizationIdParam != null) {
187
237
  jobQuery = jobQuery.where((eb: any) => eb.or([
@@ -195,127 +245,146 @@ export async function GET(req: Request) {
195
245
  .orderBy('started_at' as any, 'desc')
196
246
  .execute() as Array<Record<string, any>>
197
247
 
198
- if (!rows.length) {
199
- return { status: 'idle' as const, partitions: [] as any[] }
248
+ const rowsByEntity = new Map<string, Array<Record<string, any>>>()
249
+ for (const row of rows) {
250
+ const entityType = String(row.entity_type ?? '')
251
+ if (!entityType) continue
252
+ const bucket = rowsByEntity.get(entityType)
253
+ if (bucket) bucket.push(row)
254
+ else rowsByEntity.set(entityType, [row])
255
+ }
256
+ for (const [entityType, entityRows] of rowsByEntity) {
257
+ byEntity.set(entityType, buildJobSummary(entityRows, organizationIdParam, tenantIdParam))
200
258
  }
259
+ } catch {
260
+ return byEntity
261
+ }
262
+ return byEntity
263
+ }
201
264
 
202
- const preferOrg =
203
- organizationIdParam != null && rows.some((row: any) => row.organization_id === organizationIdParam)
204
- const pickPreferred = <T extends { startedTs: number; tenantMatch: boolean; orgMatch: boolean }>(
205
- existing: T | null,
206
- candidate: T,
207
- ): T => {
208
- if (!existing) return candidate
209
- if (preferOrg) {
210
- if (candidate.orgMatch && !existing.orgMatch) return candidate
211
- if (!candidate.orgMatch && existing.orgMatch) return existing
212
- }
213
- if (candidate.tenantMatch && !existing.tenantMatch) return candidate
214
- if (!candidate.tenantMatch && existing.tenantMatch) return existing
215
- return candidate.startedTs > existing.startedTs ? candidate : existing
265
+ function buildJobSummary(
266
+ rows: Array<Record<string, any>>,
267
+ organizationIdParam: string | null,
268
+ tenantIdParam: string | null,
269
+ ) {
270
+ if (!rows.length) {
271
+ return idleJobSummary()
272
+ }
273
+
274
+ const preferOrg =
275
+ organizationIdParam != null && rows.some((row: any) => row.organization_id === organizationIdParam)
276
+ const pickPreferred = <T extends { startedTs: number; tenantMatch: boolean; orgMatch: boolean }>(
277
+ existing: T | null,
278
+ candidate: T,
279
+ ): T => {
280
+ if (!existing) return candidate
281
+ if (preferOrg) {
282
+ if (candidate.orgMatch && !existing.orgMatch) return candidate
283
+ if (!candidate.orgMatch && existing.orgMatch) return existing
216
284
  }
285
+ if (candidate.tenantMatch && !existing.tenantMatch) return candidate
286
+ if (!candidate.tenantMatch && existing.tenantMatch) return existing
287
+ return candidate.startedTs > existing.startedTs ? candidate : existing
288
+ }
217
289
 
218
- const partitionRows = new Map<string, { row: any; startedTs: number; tenantMatch: boolean; orgMatch: boolean }>()
219
- let scopeRow: { row: any; startedTs: number; tenantMatch: boolean; orgMatch: boolean } | null = null
220
- for (const row of rows) {
221
- const key = String(row.partition_index ?? '__null__')
222
- const startedTs = row.started_at ? new Date(row.started_at).getTime() : 0
223
- const tenantMatch = tenantIdParam != null ? row.tenant_id === tenantIdParam : true
224
- const orgMatch = organizationIdParam != null ? row.organization_id === organizationIdParam : row.organization_id == null
225
- const candidate = { row, startedTs, tenantMatch, orgMatch }
226
- if (row.partition_index == null) {
227
- scopeRow = pickPreferred(scopeRow, candidate)
228
- continue
229
- }
230
- const existing = partitionRows.get(key)
231
- partitionRows.set(key, pickPreferred(existing ?? null, candidate))
290
+ const partitionRows = new Map<string, { row: any; startedTs: number; tenantMatch: boolean; orgMatch: boolean }>()
291
+ let scopeRow: { row: any; startedTs: number; tenantMatch: boolean; orgMatch: boolean } | null = null
292
+ for (const row of rows) {
293
+ const key = String(row.partition_index ?? '__null__')
294
+ const startedTs = row.started_at ? new Date(row.started_at).getTime() : 0
295
+ const tenantMatch = tenantIdParam != null ? row.tenant_id === tenantIdParam : true
296
+ const orgMatch = organizationIdParam != null ? row.organization_id === organizationIdParam : row.organization_id == null
297
+ const candidate = { row, startedTs, tenantMatch, orgMatch }
298
+ if (row.partition_index == null) {
299
+ scopeRow = pickPreferred(scopeRow, candidate)
300
+ continue
232
301
  }
302
+ const existing = partitionRows.get(key)
303
+ partitionRows.set(key, pickPreferred(existing ?? null, candidate))
304
+ }
233
305
 
234
- const partitions = Array.from(partitionRows.values())
235
- .filter((entry) => !preferOrg || entry.orgMatch)
236
- .map(({ row }) => {
237
- const heartbeatDate = row.heartbeat_at ? new Date(row.heartbeat_at) : null
238
- const startedDate = row.started_at ? new Date(row.started_at) : null
239
- const finishedDate = row.finished_at ? new Date(row.finished_at) : null
240
- const stalled =
241
- !finishedDate && (!heartbeatDate || Date.now() - heartbeatDate.getTime() > HEARTBEAT_STALE_MS)
242
- const state = finishedDate
243
- ? (row.status === 'failed' ? 'failed' : 'completed')
244
- : stalled
245
- ? 'stalled'
246
- : (row.status as string) || 'reindexing'
247
- return {
248
- partitionIndex: row.partition_index ?? null,
249
- partitionCount: row.partition_count ?? null,
250
- status: state,
251
- startedAt: startedDate ? startedDate.toISOString() : null,
252
- finishedAt: finishedDate ? finishedDate.toISOString() : null,
253
- heartbeatAt: heartbeatDate ? heartbeatDate.toISOString() : null,
254
- processedCount: row.processed_count ?? null,
255
- totalCount: row.total_count ?? null,
256
- }
257
- })
258
- .sort((a, b) => (a.partitionIndex ?? 0) - (b.partitionIndex ?? 0))
259
- const activePartitions = partitions.filter((p) => !p.finishedAt)
260
- const runningPartitions = activePartitions.filter(
261
- (p) => p.status === 'reindexing' || p.status === 'purging',
262
- )
263
- const stalledPartitions = activePartitions.filter((p) => p.status === 'stalled')
264
- const scopeCandidate = !preferOrg || !scopeRow || scopeRow.orgMatch ? scopeRow : null
265
- let status: 'idle' | 'reindexing' | 'purging' | 'stalled' | 'failed' = 'idle'
266
- if (activePartitions.length) {
267
- if (runningPartitions.length) {
268
- status = runningPartitions.some((p) => p.status === 'purging') ? 'purging' : 'reindexing'
269
- } else if (stalledPartitions.length) {
270
- status = 'stalled'
306
+ const partitions = Array.from(partitionRows.values())
307
+ .filter((entry) => !preferOrg || entry.orgMatch)
308
+ .map(({ row }) => {
309
+ const heartbeatDate = row.heartbeat_at ? new Date(row.heartbeat_at) : null
310
+ const startedDate = row.started_at ? new Date(row.started_at) : null
311
+ const finishedDate = row.finished_at ? new Date(row.finished_at) : null
312
+ const stalled =
313
+ !finishedDate && (!heartbeatDate || Date.now() - heartbeatDate.getTime() > HEARTBEAT_STALE_MS)
314
+ const state = finishedDate
315
+ ? (row.status === 'failed' ? 'failed' : 'completed')
316
+ : stalled
317
+ ? 'stalled'
318
+ : (row.status as string) || 'reindexing'
319
+ return {
320
+ partitionIndex: row.partition_index ?? null,
321
+ partitionCount: row.partition_count ?? null,
322
+ status: state,
323
+ startedAt: startedDate ? startedDate.toISOString() : null,
324
+ finishedAt: finishedDate ? finishedDate.toISOString() : null,
325
+ heartbeatAt: heartbeatDate ? heartbeatDate.toISOString() : null,
326
+ processedCount: row.processed_count ?? null,
327
+ totalCount: row.total_count ?? null,
271
328
  }
272
- } else if (
273
- partitions.some((p) => p.status === 'failed')
274
- || (scopeCandidate?.row.finished_at && scopeCandidate.row.status === 'failed')
275
- ) {
276
- // The run finished but lost records; without this it reports "idle" and the only
277
- // hint that anything went wrong is the coverage percentage.
278
- status = 'failed'
329
+ })
330
+ .sort((a, b) => (a.partitionIndex ?? 0) - (b.partitionIndex ?? 0))
331
+ const activePartitions = partitions.filter((p) => !p.finishedAt)
332
+ const runningPartitions = activePartitions.filter(
333
+ (p) => p.status === 'reindexing' || p.status === 'purging',
334
+ )
335
+ const stalledPartitions = activePartitions.filter((p) => p.status === 'stalled')
336
+ const scopeCandidate = !preferOrg || !scopeRow || scopeRow.orgMatch ? scopeRow : null
337
+ let status: 'idle' | 'reindexing' | 'purging' | 'stalled' | 'failed' = 'idle'
338
+ if (activePartitions.length) {
339
+ if (runningPartitions.length) {
340
+ status = runningPartitions.some((p) => p.status === 'purging') ? 'purging' : 'reindexing'
341
+ } else if (stalledPartitions.length) {
342
+ status = 'stalled'
279
343
  }
344
+ } else if (
345
+ partitions.some((p) => p.status === 'failed')
346
+ || (scopeCandidate?.row.finished_at && scopeCandidate.row.status === 'failed')
347
+ ) {
348
+ // The run finished but lost records; without this it reports "idle" and the only
349
+ // hint that anything went wrong is the coverage percentage.
350
+ status = 'failed'
351
+ }
280
352
 
281
- const startedAt = activePartitions[0]?.startedAt ?? partitions[0]?.startedAt ?? null
282
- const finishedAt = status === 'idle' || status === 'failed'
283
- ? (partitions.find((p) => p.finishedAt)?.finishedAt ?? null)
284
- : null
285
- const heartbeatAt = activePartitions[0]?.heartbeatAt ?? partitions[0]?.heartbeatAt ?? null
286
- const jobTotalCount = partitions.reduce((sum, p) => sum + (p.totalCount ?? 0), 0)
287
- const processedSum = partitions.reduce((sum, p) => sum + (p.processedCount ?? 0), 0)
288
- const processedCount = jobTotalCount ? Math.min(jobTotalCount, processedSum) : processedSum || null
289
-
290
- return {
291
- status,
292
- startedAt,
293
- finishedAt,
294
- heartbeatAt,
295
- processedCount: jobTotalCount ? processedCount : scopeCandidate?.row?.processed_count ?? null,
296
- totalCount: jobTotalCount ? jobTotalCount : scopeCandidate?.row?.total_count ?? null,
297
- partitions,
298
- scope: scopeCandidate
299
- ? {
300
- status: (() => {
301
- const heartbeatDate = scopeCandidate!.row.heartbeat_at ? new Date(scopeCandidate!.row.heartbeat_at) : null
302
- const finishedDate = scopeCandidate!.row.finished_at ? new Date(scopeCandidate!.row.finished_at) : null
303
- if (finishedDate) return scopeCandidate!.row.status === 'failed' ? 'failed' : 'completed'
304
- if (
305
- !heartbeatDate ||
306
- Date.now() - heartbeatDate.getTime() > HEARTBEAT_STALE_MS
307
- ) {
308
- return 'stalled'
309
- }
310
- return (scopeCandidate!.row.status as string) || 'reindexing'
311
- })(),
312
- processedCount: scopeCandidate.row.processed_count ?? null,
313
- totalCount: scopeCandidate.row.total_count ?? null,
314
- }
315
- : null,
316
- }
317
- } catch {
318
- return { status: 'idle' as const, partitions: [] as any[] }
353
+ const startedAt = activePartitions[0]?.startedAt ?? partitions[0]?.startedAt ?? null
354
+ const finishedAt = status === 'idle' || status === 'failed'
355
+ ? (partitions.find((p) => p.finishedAt)?.finishedAt ?? null)
356
+ : null
357
+ const heartbeatAt = activePartitions[0]?.heartbeatAt ?? partitions[0]?.heartbeatAt ?? null
358
+ const jobTotalCount = partitions.reduce((sum, p) => sum + (p.totalCount ?? 0), 0)
359
+ const processedSum = partitions.reduce((sum, p) => sum + (p.processedCount ?? 0), 0)
360
+ const processedCount = jobTotalCount ? Math.min(jobTotalCount, processedSum) : processedSum || null
361
+
362
+ return {
363
+ status,
364
+ startedAt,
365
+ finishedAt,
366
+ heartbeatAt,
367
+ processedCount: jobTotalCount ? processedCount : scopeCandidate?.row?.processed_count ?? null,
368
+ totalCount: jobTotalCount ? jobTotalCount : scopeCandidate?.row?.total_count ?? null,
369
+ partitions,
370
+ scope: scopeCandidate
371
+ ? {
372
+ status: (() => {
373
+ const heartbeatDate = scopeCandidate!.row.heartbeat_at ? new Date(scopeCandidate!.row.heartbeat_at) : null
374
+ const finishedDate = scopeCandidate!.row.finished_at ? new Date(scopeCandidate!.row.finished_at) : null
375
+ if (finishedDate) return scopeCandidate!.row.status === 'failed' ? 'failed' : 'completed'
376
+ if (
377
+ !heartbeatDate ||
378
+ Date.now() - heartbeatDate.getTime() > HEARTBEAT_STALE_MS
379
+ ) {
380
+ return 'stalled'
381
+ }
382
+ return (scopeCandidate!.row.status as string) || 'reindexing'
383
+ })(),
384
+ processedCount: scopeCandidate.row.processed_count ?? null,
385
+ totalCount: scopeCandidate.row.total_count ?? null,
386
+ }
387
+ : null,
319
388
  }
320
389
  }
321
390
 
@@ -337,7 +406,23 @@ export async function GET(req: Request) {
337
406
  // polled by the status table every few seconds, so the poll path must stay read-cheap:
338
407
  // stale snapshots are refreshed asynchronously via the query_index.coverage.refresh
339
408
  // event emitted below, never inline per entity.
340
- const snapshotByEntity = await readCoverageSnapshots(db, { entityTypes: entityIds, ...coverageScope })
409
+ const snapshotByEntity = await readCoverageSnapshots(db, { entityTypes: generatedIds, ...coverageScope })
410
+
411
+ const hasIndexCoverage = (entityId: string): boolean => {
412
+ const snapshot = snapshotByEntity.get(entityId)
413
+ if (!snapshot) return false
414
+ return snapshot.baseCount > 0 || snapshot.indexedCount > 0 || snapshot.vectorIndexedCount > 0
415
+ }
416
+
417
+ // Nothing this page reports is custom-field dependent: `lib/indexer.ts` builds the index
418
+ // doc from the base row and only attaches `cf:*`/`l10n:*` keys when they exist, and
419
+ // `applyEntityIndexesJoin` runs on every query regardless of custom fields. Gating the
420
+ // list on `custom_field_defs` hid every fully-indexed entity that happens to have no
421
+ // custom fields, so its coverage could neither be inspected nor reindexed from here.
422
+ // List whatever any backend actually covers and let the client filter.
423
+ const entityIds = generatedIds.filter(
424
+ (id) => searchConfiguredEntities.has(id) || customFieldEntities.has(id) || hasIndexCoverage(id),
425
+ )
341
426
 
342
427
  // An explicit refresh action (?refresh) may block, but only when the durable
343
428
  // coverage snapshots are stale. Recent persisted snapshots survive workers/restarts,
@@ -352,7 +437,7 @@ export async function GET(req: Request) {
352
437
 
353
438
  const coverageSnapshots = entityIds.map((entityId) => snapshotByEntity.get(entityId) ?? null)
354
439
 
355
- const jobs = await Promise.all(entityIds.map((eid) => fetchJobSummary(eid, tenantId, organizationId)))
440
+ const jobsByEntity = await fetchJobSummaries(entityIds, tenantId, organizationId)
356
441
 
357
442
  const items: any[] = []
358
443
  for (let idx = 0; idx < entityIds.length; idx += 1) {
@@ -363,30 +448,47 @@ export async function GET(req: Request) {
363
448
  const isStale = !coverage || !refreshedAt || (Date.now() - refreshedAt.getTime() > COVERAGE_STALE_MS)
364
449
  if (isStale) entitiesNeedingRefresh.add(eid)
365
450
 
366
- const job = jobs[idx]
451
+ const job = jobsByEntity.get(eid) ?? idleJobSummary()
367
452
  const label = (byId.get(eid)?.label) || eid
368
453
  const baseCountNumber = normalizeCount(coverage?.baseCount)
369
454
  const indexCountNumber = normalizeCount(coverage?.indexedCount)
370
- const vectorEnabled = vectorEnabledEntities.has(eid)
371
- const vectorCountNumber = vectorEnabled ? normalizeCount((coverage as any)?.vectorIndexedCount ?? (coverage as any)?.vector_indexed_count) : null
455
+ // `vectorEnabled` and `vectorCount` keep their published meaning — "the entity declares
456
+ // buildSource" and its raw coverage — so existing consumers see no change. Whether vector
457
+ // indexing can actually run ships additively as `vectorIndexingActive`.
458
+ const vectorEnabled = vectorConfiguredEntities.has(eid)
459
+ const vectorCountNumber = vectorEnabled
460
+ ? normalizeCount((coverage as any)?.vectorIndexedCount ?? (coverage as any)?.vector_indexed_count)
461
+ : null
372
462
  const fulltextEnabled = fulltextEnabledEntities.has(eid)
373
463
  const fulltextCountNumber = fulltextEnabled ? (fulltextEntityCounts?.[eid] ?? 0) : null
464
+
465
+ // `ok` keeps its published aggregate meaning (query index AND configured vector coverage)
466
+ // so consumers using it as a health signal are unaffected. The narrower signal this page
467
+ // needs — is the query index in sync with the base table — ships additively as
468
+ // `queryIndexOk`. Folding vector into the badge is what made every vector-capable entity
469
+ // read "Out of sync" while base == indexed.
374
470
  const ok = (() => {
375
471
  if (baseCountNumber == null || indexCountNumber == null) return false
376
472
  if (baseCountNumber !== indexCountNumber) return false
377
473
  if (!vectorEnabled) return true
378
474
  return vectorCountNumber != null && vectorCountNumber === baseCountNumber
379
475
  })()
476
+ const queryIndexOk = baseCountNumber != null
477
+ && indexCountNumber != null
478
+ && baseCountNumber === indexCountNumber
380
479
  items.push({
381
480
  entityId: eid,
382
481
  label,
383
482
  baseCount: baseCountNumber,
384
483
  indexCount: indexCountNumber,
385
- vectorCount: vectorEnabled ? vectorCountNumber : null,
484
+ vectorCount: vectorCountNumber,
386
485
  vectorEnabled,
486
+ vectorIndexingActive: vectorEnabled && vectorRuntimeEnabled,
387
487
  fulltextCount: fulltextCountNumber,
388
488
  fulltextEnabled,
489
+ hasCustomFields: customFieldEntities.has(eid),
389
490
  ok,
491
+ queryIndexOk,
390
492
  job,
391
493
  refreshedAt: refreshedAt ?? null,
392
494
  })