@open-mercato/core 0.6.7-develop.6706.1.b3a4c759bb → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/admin/nav.js +14 -4
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +8 -19
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +31 -14
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/customers/api/utils.js +12 -18
- package/dist/modules/customers/api/utils.js.map +2 -2
- package/dist/modules/customers/cli.js +11 -4
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +14 -10
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/lib/aggregations.js +76 -40
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
- package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +228 -5
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
- package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
- package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
- package/dist/modules/messages/lib/searchLookup.js +8 -16
- package/dist/modules/messages/lib/searchLookup.js.map +2 -2
- package/dist/modules/query_index/api/openapi.js +13 -1
- package/dist/modules/query_index/api/openapi.js.map +2 -2
- package/dist/modules/query_index/api/status.js +165 -105
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +11 -4
- package/dist/modules/query_index/lib/batch.js.map +2 -2
- package/dist/modules/query_index/lib/document.js +12 -5
- package/dist/modules/query_index/lib/document.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +36 -4
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +1 -1
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +7 -4
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +14 -3
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +22 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
- package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
- package/dist/modules/sales/data/validators.js +25 -4
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/admin/nav.ts +24 -4
- package/src/modules/auth/api/users/route.ts +8 -28
- package/src/modules/auth/lib/backendChrome.tsx +51 -13
- package/src/modules/customers/api/utils.ts +12 -31
- package/src/modules/customers/cli.ts +11 -4
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
- package/src/modules/customers/components/formConfig.tsx +12 -8
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
- package/src/modules/dashboards/lib/aggregations.ts +122 -43
- package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
- package/src/modules/dashboards/services/widgetDataService.ts +342 -4
- package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
- package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
- package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
- package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
- package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
- package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
- package/src/modules/messages/lib/searchLookup.ts +11 -34
- package/src/modules/query_index/api/openapi.ts +12 -0
- package/src/modules/query_index/api/status.ts +238 -136
- package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
- package/src/modules/query_index/i18n/de.json +5 -3
- package/src/modules/query_index/i18n/en.json +5 -3
- package/src/modules/query_index/i18n/es.json +5 -3
- package/src/modules/query_index/i18n/pl.json +5 -3
- package/src/modules/query_index/lib/batch.ts +11 -4
- package/src/modules/query_index/lib/document.ts +35 -4
- package/src/modules/query_index/lib/engine.ts +53 -5
- package/src/modules/query_index/lib/indexer.ts +1 -1
- package/src/modules/query_index/lib/search-tokens.ts +13 -4
- package/src/modules/sales/api/documents/factory.ts +28 -3
- package/src/modules/sales/commands/documents.ts +26 -2
- package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
- package/src/modules/sales/data/validators.ts +50 -5
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
let fulltextStrategy: FullTextSearchStrategy | null = null
|
|
136
|
+
let searchStrategies: unknown[] = []
|
|
130
137
|
try {
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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, '
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
371
|
-
|
|
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:
|
|
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
|
})
|