@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.
Files changed (111) 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/lib/backendChrome.js +31 -14
  7. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  8. package/dist/modules/customers/api/utils.js +12 -18
  9. package/dist/modules/customers/api/utils.js.map +2 -2
  10. package/dist/modules/customers/cli.js +11 -4
  11. package/dist/modules/customers/cli.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
  13. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  14. package/dist/modules/customers/components/formConfig.js +14 -10
  15. package/dist/modules/customers/components/formConfig.js.map +2 -2
  16. package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
  17. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  18. package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
  19. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  20. package/dist/modules/dashboards/lib/aggregations.js +76 -40
  21. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  22. package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
  23. package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
  24. package/dist/modules/dashboards/services/widgetDataService.js +228 -5
  25. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  26. package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
  27. package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
  28. package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
  29. package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
  30. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
  31. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
  32. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
  33. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
  34. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
  35. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
  36. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
  37. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
  38. package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
  39. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  40. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
  41. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
  42. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
  43. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  44. package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
  45. package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
  46. package/dist/modules/messages/lib/searchLookup.js +8 -16
  47. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  48. package/dist/modules/query_index/api/openapi.js +13 -1
  49. package/dist/modules/query_index/api/openapi.js.map +2 -2
  50. package/dist/modules/query_index/api/status.js +165 -105
  51. package/dist/modules/query_index/api/status.js.map +2 -2
  52. package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
  53. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  54. package/dist/modules/query_index/lib/batch.js +11 -4
  55. package/dist/modules/query_index/lib/batch.js.map +2 -2
  56. package/dist/modules/query_index/lib/document.js +12 -5
  57. package/dist/modules/query_index/lib/document.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +36 -4
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/dist/modules/query_index/lib/indexer.js +1 -1
  61. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  62. package/dist/modules/query_index/lib/search-tokens.js +7 -4
  63. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  64. package/dist/modules/sales/api/documents/factory.js +14 -3
  65. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  66. package/dist/modules/sales/commands/documents.js +22 -1
  67. package/dist/modules/sales/commands/documents.js.map +2 -2
  68. package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
  69. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
  70. package/dist/modules/sales/data/validators.js +25 -4
  71. package/dist/modules/sales/data/validators.js.map +2 -2
  72. package/package.json +7 -7
  73. package/src/modules/auth/api/admin/nav.ts +24 -4
  74. package/src/modules/auth/api/users/route.ts +8 -28
  75. package/src/modules/auth/lib/backendChrome.tsx +51 -13
  76. package/src/modules/customers/api/utils.ts +12 -31
  77. package/src/modules/customers/cli.ts +11 -4
  78. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
  79. package/src/modules/customers/components/formConfig.tsx +12 -8
  80. package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
  81. package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
  82. package/src/modules/dashboards/lib/aggregations.ts +122 -43
  83. package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
  84. package/src/modules/dashboards/services/widgetDataService.ts +342 -4
  85. package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
  86. package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
  87. package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
  88. package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
  89. package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
  90. package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
  91. package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
  92. package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
  93. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
  94. package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
  95. package/src/modules/messages/lib/searchLookup.ts +11 -34
  96. package/src/modules/query_index/api/openapi.ts +12 -0
  97. package/src/modules/query_index/api/status.ts +238 -136
  98. package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
  99. package/src/modules/query_index/i18n/de.json +5 -3
  100. package/src/modules/query_index/i18n/en.json +5 -3
  101. package/src/modules/query_index/i18n/es.json +5 -3
  102. package/src/modules/query_index/i18n/pl.json +5 -3
  103. package/src/modules/query_index/lib/batch.ts +11 -4
  104. package/src/modules/query_index/lib/document.ts +35 -4
  105. package/src/modules/query_index/lib/engine.ts +53 -5
  106. package/src/modules/query_index/lib/indexer.ts +1 -1
  107. package/src/modules/query_index/lib/search-tokens.ts +13 -4
  108. package/src/modules/sales/api/documents/factory.ts +28 -3
  109. package/src/modules/sales/commands/documents.ts +26 -2
  110. package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
  111. package/src/modules/sales/data/validators.ts +50 -5
@@ -3,6 +3,7 @@ import * as React from 'react'
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query'
4
4
  import type { ColumnDef, SortingState } from '@tanstack/react-table'
5
5
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
6
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
6
7
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
7
8
  import { Button } from '@open-mercato/ui/primitives/button'
8
9
  import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
@@ -52,15 +53,54 @@ type Row = {
52
53
  baseCount: number | null
53
54
  indexCount: number | null
54
55
  vectorCount: number | null
56
+ /** The entity is configured for vector search (declares `buildSource`). */
55
57
  vectorEnabled: boolean
58
+ /** Vector indexing can actually run — provider reachable and auto-indexing on. */
59
+ vectorIndexingActive?: boolean
56
60
  fulltextCount: number | null
57
61
  fulltextEnabled: boolean
62
+ hasCustomFields?: boolean
63
+ /** Legacy aggregate: query index AND vector coverage. Preserved for API consumers. */
58
64
  ok: boolean
65
+ /** Query index alone is in sync with the base table — the signal this page reports. */
66
+ queryIndexOk?: boolean
59
67
  job?: JobStatus
60
68
  }
61
69
 
62
70
  type Resp = { items: Row[] }
63
71
 
72
+ const BACKEND_FILTER_ID = 'backend'
73
+ // Kept at or below the repository-wide 100-row cap; the entity list grows with every
74
+ // installed module, so it cannot be allowed to scale with the row count.
75
+ const PAGE_SIZE = 50
76
+
77
+ /** Sort key for a column id. Mirrors the column `accessorFn`s so paging and sorting agree. */
78
+ function sortValue(row: Row, columnId: string): string | number {
79
+ if (columnId === 'entityId') return row.entityId
80
+ if (columnId === 'baseCount') return row.baseCount ?? 0
81
+ if (columnId === 'indexCount') return row.indexCount ?? 0
82
+ if (columnId === 'vectorCount') return isVectorReportable(row) ? row.vectorCount ?? 0 : -1
83
+ if (columnId === 'fulltextCount') return row.fulltextEnabled ? row.fulltextCount ?? 0 : -1
84
+ return row.entityId
85
+ }
86
+
87
+ type BackendFilter = 'vector' | 'fulltext' | 'custom_fields'
88
+
89
+ /**
90
+ * Vector coverage is only worth showing when embeddings can actually be written; otherwise
91
+ * the count is a permanent 0 that reads as a gap. Older payloads without the flag are
92
+ * treated as active so the column keeps working against an un-upgraded server.
93
+ */
94
+ function isVectorReportable(row: Row): boolean {
95
+ return row.vectorEnabled && (row.vectorIndexingActive ?? true)
96
+ }
97
+
98
+ function matchesBackendFilter(row: Row, backend: BackendFilter): boolean {
99
+ if (backend === 'vector') return row.vectorEnabled
100
+ if (backend === 'fulltext') return row.fulltextEnabled
101
+ return Boolean(row.hasCustomFields)
102
+ }
103
+
64
104
  function formatCount(value: number | null): string {
65
105
  if (value == null) return '—'
66
106
  return value.toLocaleString()
@@ -83,15 +123,24 @@ function formatProgressLabel(
83
123
  return t('query_index.table.status.progressSingle', { processed: processedText })
84
124
  }
85
125
 
86
- function translateJobStatus(t: Translator, status: JobStatus['status'] | undefined, ok: boolean): string {
87
- if (!status || status === 'idle') {
126
+ function translateJobStatus(
127
+ t: Translator,
128
+ status: JobStatus['status'] | undefined,
129
+ ok: boolean,
130
+ measured: boolean,
131
+ ): string {
132
+ const idleLabel = () => {
133
+ // No coverage snapshot yet — an async refresh is pending, so the index is not known to
134
+ // be out of sync. Saying so would be the same false alarm this page used to raise.
135
+ if (!measured) return t('query_index.table.status.not_measured')
88
136
  return ok ? t('query_index.table.status.in_sync') : t('query_index.table.status.out_of_sync')
89
137
  }
138
+ if (!status || status === 'idle') return idleLabel()
90
139
  if (status === 'reindexing') return t('query_index.table.status.reindexing')
91
140
  if (status === 'purging') return t('query_index.table.status.purging')
92
141
  if (status === 'stalled') return t('query_index.table.status.stalled')
93
142
  if (status === 'failed') return t('query_index.table.status.failed')
94
- return ok ? t('query_index.table.status.in_sync') : t('query_index.table.status.out_of_sync')
143
+ return idleLabel()
95
144
  }
96
145
 
97
146
  function translateScopeStatus(
@@ -108,8 +157,15 @@ function translateScopeStatus(
108
157
 
109
158
  function createColumns(t: Translator): ColumnDef<Row>[] {
110
159
  return [
111
- { id: 'entityId', header: () => t('query_index.table.columns.entity'), accessorKey: 'entityId', meta: { priority: 1 } },
112
- { id: 'label', header: () => t('query_index.table.columns.label'), accessorKey: 'label', meta: { priority: 2 } },
160
+ {
161
+ id: 'entityId',
162
+ header: () => t('query_index.table.columns.entity'),
163
+ accessorKey: 'entityId',
164
+ // The entity id is the only identifier on this page and ids run to ~45 characters
165
+ // (`catalog:catalog_product_category_assignment`). DataTable's default 150px clamp
166
+ // ellipsised them into indistinguishable `customers:customer…` rows.
167
+ meta: { priority: 1, maxWidth: '340px' },
168
+ },
113
169
  {
114
170
  id: 'baseCount',
115
171
  header: () => t('query_index.table.columns.records'),
@@ -127,10 +183,10 @@ function createColumns(t: Translator): ColumnDef<Row>[] {
127
183
  {
128
184
  id: 'vectorCount',
129
185
  header: () => t('query_index.table.columns.vector'),
130
- accessorFn: (row) => (row.vectorEnabled ? row.vectorCount ?? 0 : -1),
186
+ accessorFn: (row) => (isVectorReportable(row) ? row.vectorCount ?? 0 : -1),
131
187
  cell: ({ row }) => {
132
188
  const record = row.original
133
- if (!record.vectorEnabled) return <span>—</span>
189
+ if (!isVectorReportable(record)) return <span>—</span>
134
190
  const ok = record.vectorCount != null && record.baseCount != null && record.vectorCount === record.baseCount
135
191
  const display = formatCount(record.vectorCount)
136
192
  const className = ok ? 'text-status-success-text' : 'text-status-warning-text'
@@ -159,17 +215,45 @@ function createColumns(t: Translator): ColumnDef<Row>[] {
159
215
  const record = row.original
160
216
  const job = record.job
161
217
  const partitions = job?.partitions ?? []
162
- const ok = record.ok && (!job || job.status === 'idle')
163
- const jobFailed = job?.status === 'failed'
164
- const statusText = translateJobStatus(t, job?.status, ok)
165
- const jobProgress = job ? formatProgressLabel(job.processedCount ?? null, job.totalCount ?? null, t) : null
218
+ const measured = record.baseCount != null || record.indexCount != null
219
+
220
+ // A non-partitioned reindex — and every purge, which never partitions
221
+ // (`lib/purge.ts` always passes `partitionIndex: null`) — is tracked as a scope row.
222
+ // The server derives the top-level status from partition rows only, so it reports
223
+ // `idle` for those runs; without folding the scope row in, the default reindex and
224
+ // every purge would render as though nothing were happening.
225
+ const scopeStatus = job?.scope?.status ?? null
226
+ const scopeInFlight = scopeStatus != null && scopeStatus !== 'completed'
227
+ const effectiveStatus: JobStatus['status'] | undefined = job
228
+ ? (job.status !== 'idle'
229
+ ? job.status
230
+ : (scopeInFlight ? (scopeStatus as JobStatus['status']) : 'idle'))
231
+ : undefined
232
+
233
+ const indexOk = record.queryIndexOk ?? record.ok
234
+ const ok = indexOk && (!job || effectiveStatus === 'idle')
235
+ const jobFailed = effectiveStatus === 'failed'
236
+ const statusText = translateJobStatus(t, effectiveStatus, ok, measured)
237
+
238
+ const jobInFlight = Boolean(effectiveStatus && effectiveStatus !== 'idle')
239
+ || partitions.some((part) => part.status !== 'completed')
240
+
241
+ // Job counters and index coverage are different numbers: the counters describe the
242
+ // last reindex run, the Records/Indexed columns describe the index right now. While
243
+ // a job runs, "Reindexing (5/14)" is the only place progress is visible. Once it is
244
+ // idle the counters are stale and duplicate the columns — worse, they can contradict
245
+ // them: an entity whose last run processed 14 rows but landed 2 in the index rendered
246
+ // "Out of sync (14/14)" next to Records 14 / Indexed 2.
247
+ const jobProgress = jobInFlight && job
248
+ ? formatProgressLabel(job.processedCount ?? null, job.totalCount ?? null, t)
249
+ : null
166
250
  const label = jobProgress
167
251
  ? t('query_index.table.status.withProgress', { status: statusText, progress: jobProgress })
168
252
  : statusText
169
253
  let variant: StatusBadgeVariant = 'neutral'
170
254
  if (job) {
171
- if (job.status === 'stalled' || jobFailed) variant = 'error'
172
- else if (job.status === 'reindexing' || job.status === 'purging') variant = 'warning'
255
+ if (effectiveStatus === 'stalled' || jobFailed) variant = 'error'
256
+ else if (effectiveStatus === 'reindexing' || effectiveStatus === 'purging') variant = 'warning'
173
257
  else variant = ok ? 'success' : 'neutral'
174
258
  } else {
175
259
  variant = ok ? 'success' : 'neutral'
@@ -177,33 +261,28 @@ function createColumns(t: Translator): ColumnDef<Row>[] {
177
261
 
178
262
  const lines: string[] = []
179
263
 
180
- if (job?.scope && partitions.length <= 1) {
181
- const scopeStatus = translateScopeStatus(t, job.scope.status ?? null)
182
- const scopeProgress = formatProgressLabel(job.scope.processedCount ?? null, job.scope.totalCount ?? null, t)
183
- const scopeLabel = t('query_index.table.status.scopeLabel')
184
- lines.push(`${scopeLabel}: ${scopeStatus}${scopeProgress ? ` (${scopeProgress})` : ''}`)
185
- }
186
-
187
- if (partitions.length > 1) {
188
- for (const part of partitions) {
189
- const partitionLabel =
190
- part.partitionIndex != null
191
- ? t('query_index.table.status.partitionLabel', { index: Number(part.partitionIndex) + 1 })
192
- : t('query_index.table.status.scopeLabel')
193
- const partitionStatus = translateScopeStatus(t, part.status)
194
- const partitionProgress = formatProgressLabel(part.processedCount ?? null, part.totalCount ?? null, t)
195
- lines.push(`${partitionLabel}: ${partitionStatus}${partitionProgress ? ` (${partitionProgress})` : ''}`)
264
+ // Per-partition detail earns its space only while work is in flight — that is when
265
+ // it tells you which partition is running or stuck. Once the job is idle and every
266
+ // partition is finished, each line just restates the badge, on every row.
267
+ if (jobInFlight) {
268
+ if (job?.scope && partitions.length <= 1) {
269
+ const scopeStatusText = translateScopeStatus(t, scopeStatus)
270
+ const scopeProgress = formatProgressLabel(job.scope.processedCount ?? null, job.scope.totalCount ?? null, t)
271
+ const scopeLabel = t('query_index.table.status.scopeLabel')
272
+ lines.push(`${scopeLabel}: ${scopeStatusText}${scopeProgress ? ` (${scopeProgress})` : ''}`)
196
273
  }
197
- }
198
274
 
199
- if (record.vectorEnabled) {
200
- const vectorLabel = t('query_index.table.status.vectorLabel')
201
- const vectorCount = formatCount(record.vectorCount)
202
- const vectorTotal = record.baseCount != null ? formatCount(record.baseCount) : null
203
- const vectorValue = vectorTotal
204
- ? t('query_index.table.status.vectorValue', { count: vectorCount, total: vectorTotal })
205
- : vectorCount
206
- lines.push(`${vectorLabel}: ${vectorValue}`)
275
+ if (partitions.length > 1) {
276
+ for (const part of partitions) {
277
+ const partitionLabel =
278
+ part.partitionIndex != null
279
+ ? t('query_index.table.status.partitionLabel', { index: Number(part.partitionIndex) + 1 })
280
+ : t('query_index.table.status.scopeLabel')
281
+ const partitionStatus = translateScopeStatus(t, part.status)
282
+ const partitionProgress = formatProgressLabel(part.processedCount ?? null, part.totalCount ?? null, t)
283
+ lines.push(`${partitionLabel}: ${partitionStatus}${partitionProgress ? ` (${partitionProgress})` : ''}`)
284
+ }
285
+ }
207
286
  }
208
287
 
209
288
  return (
@@ -230,14 +309,27 @@ function createColumns(t: Translator): ColumnDef<Row>[] {
230
309
 
231
310
  export default function QueryIndexesTable() {
232
311
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'entityId', desc: false }])
233
- const [page, setPage] = React.useState(1)
234
312
  const [search, setSearch] = React.useState('')
313
+ const [page, setPage] = React.useState(1)
314
+ const [filterValues, setFilterValues] = React.useState<FilterValues>({})
235
315
  const qc = useQueryClient()
236
316
  const scopeVersion = useOrganizationScopeVersion()
237
317
  const [refreshSeq, setRefreshSeq] = React.useState(0)
238
318
  const t = useT()
239
319
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
240
320
  const columns = React.useMemo(() => createColumns(t), [t])
321
+ const filters = React.useMemo<FilterDef[]>(() => [
322
+ {
323
+ id: BACKEND_FILTER_ID,
324
+ label: t('query_index.table.filters.backend'),
325
+ type: 'select',
326
+ options: [
327
+ { label: t('query_index.table.filters.backendOptions.vector'), value: 'vector' },
328
+ { label: t('query_index.table.filters.backendOptions.fulltext'), value: 'fulltext' },
329
+ { label: t('query_index.table.filters.backendOptions.customFields'), value: 'custom_fields' },
330
+ ],
331
+ },
332
+ ], [t])
241
333
  const { runMutation, retryLastMutation } = useGuardedMutation<{
242
334
  formId: string
243
335
  resourceKind: string
@@ -263,11 +355,49 @@ export default function QueryIndexesTable() {
263
355
  })
264
356
 
265
357
  const rowsAll = data?.items || []
266
- const rows = React.useMemo(() => {
267
- if (!search) return rowsAll
268
- const q = search.toLowerCase()
269
- return rowsAll.filter((r) => r.entityId.toLowerCase().includes(q) || r.label.toLowerCase().includes(q))
270
- }, [rowsAll, search])
358
+ const matchedRows = React.useMemo(() => {
359
+ const backend = filterValues[BACKEND_FILTER_ID] as BackendFilter | undefined
360
+ const q = search.trim().toLowerCase()
361
+ if (!q && !backend) return rowsAll
362
+ return rowsAll.filter((row) => {
363
+ if (q && !row.entityId.toLowerCase().includes(q)) return false
364
+ if (backend && !matchesBackendFilter(row, backend)) return false
365
+ return true
366
+ })
367
+ }, [rowsAll, search, filterValues])
368
+
369
+ // The endpoint returns every entity in one payload, so paging is ours to do. Sorting has
370
+ // to happen here rather than in the table: slicing first would let DataTable sort only the
371
+ // visible page, so page 2 would not hold the rows that sort into it.
372
+ const sortedRows = React.useMemo(() => {
373
+ const sort = sorting[0]
374
+ if (!sort) return matchedRows
375
+ const direction = sort.desc ? -1 : 1
376
+ return [...matchedRows].sort((a, b) => {
377
+ const left = sortValue(a, sort.id)
378
+ const right = sortValue(b, sort.id)
379
+ if (typeof left === 'string' || typeof right === 'string') {
380
+ return String(left).localeCompare(String(right)) * direction
381
+ }
382
+ return (Number(left) - Number(right)) * direction
383
+ })
384
+ }, [matchedRows, sorting])
385
+
386
+ const total = sortedRows.length
387
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
388
+ const currentPage = Math.min(page, totalPages)
389
+ const rows = React.useMemo(
390
+ () => sortedRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
391
+ [sortedRows, currentPage],
392
+ )
393
+
394
+ const pagination = React.useMemo(() => ({
395
+ page: currentPage,
396
+ pageSize: PAGE_SIZE,
397
+ total,
398
+ totalPages,
399
+ onPageChange: setPage,
400
+ }), [currentPage, total, totalPages])
271
401
 
272
402
  const trigger = React.useCallback(
273
403
  async (action: 'reindex' | 'purge', entityId: string, opts?: { force?: boolean }) => {
@@ -416,9 +546,23 @@ export default function QueryIndexesTable() {
416
546
  setSearch(value)
417
547
  setPage(1)
418
548
  }}
549
+ filters={filters}
550
+ filterValues={filterValues}
551
+ onFiltersApply={(values) => {
552
+ setFilterValues(values)
553
+ setPage(1)
554
+ }}
555
+ onFiltersClear={() => {
556
+ setFilterValues({})
557
+ setPage(1)
558
+ }}
419
559
  sortable
560
+ manualSorting
420
561
  sorting={sorting}
421
- onSortingChange={setSorting}
562
+ onSortingChange={(next) => {
563
+ setSorting(next)
564
+ setPage(1)
565
+ }}
422
566
  perspective={{ tableId: 'query_index.status.list' }}
423
567
  rowActions={(row) => {
424
568
  const items: Array<{ id: string; label: string; onSelect: () => void; destructive?: boolean }> = [
@@ -436,6 +580,8 @@ export default function QueryIndexesTable() {
436
580
  },
437
581
  ]
438
582
 
583
+ // Manual vector actions stay available whenever the entity declares `buildSource`,
584
+ // even when auto-indexing is off — a manual reindex is still a valid operation.
439
585
  if (row.vectorEnabled) {
440
586
  items.push(
441
587
  {
@@ -470,7 +616,7 @@ export default function QueryIndexesTable() {
470
616
 
471
617
  return <RowActions items={items} />
472
618
  }}
473
- pagination={{ page, pageSize: 50, total: rows.length, totalPages: 1, onPageChange: setPage }}
619
+ pagination={pagination}
474
620
  isLoading={isLoading}
475
621
  />
476
622
  {ConfirmDialogElement}
@@ -18,7 +18,6 @@
18
18
  "query_index.table.columns.entity": "Entität",
19
19
  "query_index.table.columns.fulltext": "Volltext",
20
20
  "query_index.table.columns.indexed": "Indexiert",
21
- "query_index.table.columns.label": "Beschriftung",
22
21
  "query_index.table.columns.records": "Datensätze",
23
22
  "query_index.table.columns.status": "Status",
24
23
  "query_index.table.columns.vector": "Vektor",
@@ -35,6 +34,10 @@
35
34
  "query_index.table.errors.subtitle": "Die letzten 100 Fehler für Query- und Vektor-Indexer in diesem Bereich.",
36
35
  "query_index.table.errors.title": "Aktuelle Indexer-Fehler",
37
36
  "query_index.table.errors.viewDetails": "Technische Details anzeigen",
37
+ "query_index.table.filters.backend": "Backend",
38
+ "query_index.table.filters.backendOptions.customFields": "Benutzerdefinierte Felder",
39
+ "query_index.table.filters.backendOptions.fulltext": "Volltextsuche",
40
+ "query_index.table.filters.backendOptions.vector": "Vektorsuche",
38
41
  "query_index.table.logs.columns.message": "Nachricht",
39
42
  "query_index.table.logs.columns.source": "Quelle",
40
43
  "query_index.table.logs.columns.timestamp": "Zeitstempel",
@@ -48,6 +51,7 @@
48
51
  "query_index.table.searchPlaceholder": "Nach Entität filtern",
49
52
  "query_index.table.status.failed": "Fehlgeschlagen",
50
53
  "query_index.table.status.in_sync": "Synchron",
54
+ "query_index.table.status.not_measured": "Noch nicht gemessen",
51
55
  "query_index.table.status.out_of_sync": "Nicht synchron",
52
56
  "query_index.table.status.partitionLabel": "Partition {{index}}",
53
57
  "query_index.table.status.progress": "{{processed}}/{{total}}",
@@ -61,7 +65,5 @@
61
65
  "query_index.table.status.scope.stalled": "Angehalten",
62
66
  "query_index.table.status.scopeLabel": "Bereich",
63
67
  "query_index.table.status.stalled": "Angehalten",
64
- "query_index.table.status.vectorLabel": "Vektor",
65
- "query_index.table.status.vectorValue": "{{count}} / {{total}}",
66
68
  "query_index.table.status.withProgress": "{{status}} ({{progress}})"
67
69
  }
@@ -18,7 +18,6 @@
18
18
  "query_index.table.columns.entity": "Entity",
19
19
  "query_index.table.columns.fulltext": "Full-text",
20
20
  "query_index.table.columns.indexed": "Indexed",
21
- "query_index.table.columns.label": "Label",
22
21
  "query_index.table.columns.records": "Records",
23
22
  "query_index.table.columns.status": "Status",
24
23
  "query_index.table.columns.vector": "Vector",
@@ -35,6 +34,10 @@
35
34
  "query_index.table.errors.subtitle": "Last 100 errors recorded for query index and vector index jobs in this scope.",
36
35
  "query_index.table.errors.title": "Recent indexer errors",
37
36
  "query_index.table.errors.viewDetails": "View technical details",
37
+ "query_index.table.filters.backend": "Backend",
38
+ "query_index.table.filters.backendOptions.customFields": "Custom fields",
39
+ "query_index.table.filters.backendOptions.fulltext": "Full-text search",
40
+ "query_index.table.filters.backendOptions.vector": "Vector search",
38
41
  "query_index.table.logs.columns.message": "Message",
39
42
  "query_index.table.logs.columns.source": "Source",
40
43
  "query_index.table.logs.columns.timestamp": "Timestamp",
@@ -48,6 +51,7 @@
48
51
  "query_index.table.searchPlaceholder": "Filter by entity",
49
52
  "query_index.table.status.failed": "Failed",
50
53
  "query_index.table.status.in_sync": "In sync",
54
+ "query_index.table.status.not_measured": "Not measured yet",
51
55
  "query_index.table.status.out_of_sync": "Out of sync",
52
56
  "query_index.table.status.partitionLabel": "Partition {{index}}",
53
57
  "query_index.table.status.progress": "{{processed}}/{{total}}",
@@ -61,7 +65,5 @@
61
65
  "query_index.table.status.scope.stalled": "Stalled",
62
66
  "query_index.table.status.scopeLabel": "Scope",
63
67
  "query_index.table.status.stalled": "Stalled",
64
- "query_index.table.status.vectorLabel": "Vector",
65
- "query_index.table.status.vectorValue": "{{count}} / {{total}}",
66
68
  "query_index.table.status.withProgress": "{{status}} ({{progress}})"
67
69
  }
@@ -18,7 +18,6 @@
18
18
  "query_index.table.columns.entity": "Entidad",
19
19
  "query_index.table.columns.fulltext": "Texto completo",
20
20
  "query_index.table.columns.indexed": "Indexados",
21
- "query_index.table.columns.label": "Etiqueta",
22
21
  "query_index.table.columns.records": "Registros",
23
22
  "query_index.table.columns.status": "Estado",
24
23
  "query_index.table.columns.vector": "Vectorial",
@@ -35,6 +34,10 @@
35
34
  "query_index.table.errors.subtitle": "Últimos 100 errores registrados para los trabajos del índice de consultas y vector en este ámbito.",
36
35
  "query_index.table.errors.title": "Errores recientes del indexador",
37
36
  "query_index.table.errors.viewDetails": "Ver detalles técnicos",
37
+ "query_index.table.filters.backend": "Backend",
38
+ "query_index.table.filters.backendOptions.customFields": "Campos personalizados",
39
+ "query_index.table.filters.backendOptions.fulltext": "Búsqueda de texto completo",
40
+ "query_index.table.filters.backendOptions.vector": "Búsqueda vectorial",
38
41
  "query_index.table.logs.columns.message": "Mensaje",
39
42
  "query_index.table.logs.columns.source": "Origen",
40
43
  "query_index.table.logs.columns.timestamp": "Marca de tiempo",
@@ -48,6 +51,7 @@
48
51
  "query_index.table.searchPlaceholder": "Filtrar por entidad",
49
52
  "query_index.table.status.failed": "Con errores",
50
53
  "query_index.table.status.in_sync": "Sincronizado",
54
+ "query_index.table.status.not_measured": "Aún no medido",
51
55
  "query_index.table.status.out_of_sync": "Fuera de sincronización",
52
56
  "query_index.table.status.partitionLabel": "Partición {{index}}",
53
57
  "query_index.table.status.progress": "{{processed}}/{{total}}",
@@ -61,7 +65,5 @@
61
65
  "query_index.table.status.scope.stalled": "Detenido",
62
66
  "query_index.table.status.scopeLabel": "Ámbito",
63
67
  "query_index.table.status.stalled": "Detenido",
64
- "query_index.table.status.vectorLabel": "Vectorial",
65
- "query_index.table.status.vectorValue": "{{count}} / {{total}}",
66
68
  "query_index.table.status.withProgress": "{{status}} ({{progress}})"
67
69
  }
@@ -18,7 +18,6 @@
18
18
  "query_index.table.columns.entity": "Encja",
19
19
  "query_index.table.columns.fulltext": "Pełnotekstowy",
20
20
  "query_index.table.columns.indexed": "Zindeksowane",
21
- "query_index.table.columns.label": "Etykieta",
22
21
  "query_index.table.columns.records": "Rekordy",
23
22
  "query_index.table.columns.status": "Status",
24
23
  "query_index.table.columns.vector": "Wektor",
@@ -35,6 +34,10 @@
35
34
  "query_index.table.errors.subtitle": "Ostatnie 100 błędów zapisanych dla zadań indeksu zapytań i wektorowego w tym zakresie.",
36
35
  "query_index.table.errors.title": "Ostatnie błędy indeksowania",
37
36
  "query_index.table.errors.viewDetails": "Pokaż szczegóły techniczne",
37
+ "query_index.table.filters.backend": "Backend",
38
+ "query_index.table.filters.backendOptions.customFields": "Pola niestandardowe",
39
+ "query_index.table.filters.backendOptions.fulltext": "Wyszukiwanie pełnotekstowe",
40
+ "query_index.table.filters.backendOptions.vector": "Wyszukiwanie wektorowe",
38
41
  "query_index.table.logs.columns.message": "Komunikat",
39
42
  "query_index.table.logs.columns.source": "Źródło",
40
43
  "query_index.table.logs.columns.timestamp": "Znacznik czasu",
@@ -48,6 +51,7 @@
48
51
  "query_index.table.searchPlaceholder": "Filtruj według encji",
49
52
  "query_index.table.status.failed": "Niepowodzenie",
50
53
  "query_index.table.status.in_sync": "Zsynchronizowany",
54
+ "query_index.table.status.not_measured": "Jeszcze nie zmierzono",
51
55
  "query_index.table.status.out_of_sync": "Poza synchronizacją",
52
56
  "query_index.table.status.partitionLabel": "Partycja {{index}}",
53
57
  "query_index.table.status.progress": "{{processed}}/{{total}}",
@@ -61,7 +65,5 @@
61
65
  "query_index.table.status.scope.stalled": "Zatrzymane",
62
66
  "query_index.table.status.scopeLabel": "Zakres",
63
67
  "query_index.table.status.stalled": "Zatrzymany",
64
- "query_index.table.status.vectorLabel": "Wektor",
65
- "query_index.table.status.vectorValue": "{{count}} / {{total}}",
66
68
  "query_index.table.status.withProgress": "{{status}} ({{progress}})"
67
69
  }
@@ -4,6 +4,7 @@ import { isUniqueViolation } from '@open-mercato/shared/lib/db/pg-errors'
4
4
  import { buildIndexDocument, type IndexCustomFieldValue } from './document'
5
5
  import { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'
6
6
  import { createLogger } from '@open-mercato/shared/lib/logger'
7
+ import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
7
8
 
8
9
  const logger = createLogger('query_index').child({ component: 'reindex-batch' })
9
10
 
@@ -228,6 +229,7 @@ export async function upsertIndexBatch(
228
229
  const basePayloads: IndexRowPayload[] = []
229
230
 
230
231
  const debugEnabled = isSearchDebugEnabled()
232
+ const searchConfig = resolveSearchConfig()
231
233
 
232
234
  for (const row of rows) {
233
235
  const recordId = normalizeId(row.id)
@@ -264,10 +266,15 @@ export async function upsertIndexBatch(
264
266
  if (!entityRow) return row
265
267
  return { ...entityRow, ...row }
266
268
  })()
267
- let doc = buildIndexDocument(mergedRow, values, {
268
- organizationId: scopeOrg ?? null,
269
- tenantId: scopeTenant ?? null,
270
- })
269
+ let doc = buildIndexDocument(
270
+ mergedRow,
271
+ values,
272
+ {
273
+ organizationId: scopeOrg ?? null,
274
+ tenantId: scopeTenant ?? null,
275
+ },
276
+ { entityType, config: searchConfig },
277
+ )
271
278
  let tokenDoc: Record<string, unknown> = doc
272
279
  if (typeof options.encryptDoc === 'function') {
273
280
  try {
@@ -1,3 +1,9 @@
1
+ import {
2
+ isSearchFieldBlocklisted,
3
+ resolveSearchConfig,
4
+ type SearchConfig,
5
+ } from '@open-mercato/shared/lib/search/config'
6
+
1
7
  export type IndexDocumentScope = {
2
8
  organizationId?: string | null
3
9
  tenantId?: string | null
@@ -12,6 +18,18 @@ export type IndexCustomFieldValue = {
12
18
 
13
19
  export const AGGREGATE_SEARCH_FIELD = 'search_text'
14
20
 
21
+ /**
22
+ * Controls which fields may contribute text to the `search_text` aggregate.
23
+ *
24
+ * Both properties are optional so existing callers keep working unchanged: an
25
+ * omitted `config` is resolved from the environment, and an omitted `entityType`
26
+ * means only the global blocklist entries apply.
27
+ */
28
+ export type AggregateSearchOptions = {
29
+ entityType?: string | null
30
+ config?: SearchConfig
31
+ }
32
+
15
33
  function normalizeScopeValue(value: string | null | undefined): string | null {
16
34
  if (value === undefined || value === null || value === '') return null
17
35
  return value
@@ -30,7 +48,12 @@ function normalizeValue(value: unknown): unknown {
30
48
  return value
31
49
  }
32
50
 
33
- function collectAggregateSearchValues(field: string, value: unknown): string[] {
51
+ function collectAggregateSearchValues(
52
+ field: string,
53
+ value: unknown,
54
+ entityType: string | null,
55
+ config: SearchConfig,
56
+ ): string[] {
34
57
  const lower = field.toLowerCase()
35
58
  if (
36
59
  lower === AGGREGATE_SEARCH_FIELD
@@ -43,6 +66,8 @@ function collectAggregateSearchValues(field: string, value: unknown): string[] {
43
66
  return []
44
67
  }
45
68
 
69
+ if (isSearchFieldBlocklisted(field, entityType, config)) return []
70
+
46
71
  if (typeof value === 'string') {
47
72
  const trimmed = value.trim()
48
73
  return trimmed.length > 0 ? [trimmed] : []
@@ -58,12 +83,17 @@ function collectAggregateSearchValues(field: string, value: unknown): string[] {
58
83
  return []
59
84
  }
60
85
 
61
- export function attachAggregateSearchField(doc: Record<string, unknown>): Record<string, unknown> {
86
+ export function attachAggregateSearchField(
87
+ doc: Record<string, unknown>,
88
+ options: AggregateSearchOptions = {},
89
+ ): Record<string, unknown> {
90
+ const config = options.config ?? resolveSearchConfig()
91
+ const entityType = options.entityType ?? null
62
92
  const parts: string[] = []
63
93
  const seen = new Set<string>()
64
94
 
65
95
  for (const [field, value] of Object.entries(doc)) {
66
- const values = collectAggregateSearchValues(field, value)
96
+ const values = collectAggregateSearchValues(field, value, entityType, config)
67
97
  for (const entry of values) {
68
98
  const key = entry.toLowerCase()
69
99
  if (seen.has(key)) continue
@@ -83,6 +113,7 @@ export function buildIndexDocument(
83
113
  baseRow: Record<string, unknown>,
84
114
  customFieldValues: Iterable<IndexCustomFieldValue> = [],
85
115
  scope: IndexDocumentScope = {},
116
+ options: AggregateSearchOptions = {},
86
117
  ): Record<string, unknown> {
87
118
  const doc: Record<string, unknown> = {}
88
119
  for (const [key, value] of Object.entries(baseRow)) {
@@ -123,5 +154,5 @@ export function buildIndexDocument(
123
154
  }
124
155
  }
125
156
 
126
- return attachAggregateSearchField(doc)
157
+ return attachAggregateSearchField(doc, options)
127
158
  }