@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6851.1.c1680b00d8

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 (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/entities/api/definitions.js +2 -0
  3. package/dist/modules/entities/api/definitions.js.map +2 -2
  4. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  5. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  6. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  7. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  8. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  9. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  10. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  11. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  12. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  13. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  14. package/dist/modules/query_index/api/reindex.js +10 -0
  15. package/dist/modules/query_index/api/reindex.js.map +2 -2
  16. package/dist/modules/query_index/cli.js +125 -1
  17. package/dist/modules/query_index/cli.js.map +2 -2
  18. package/dist/modules/query_index/lib/coverage.js +19 -7
  19. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  20. package/dist/modules/query_index/lib/reindexer.js +43 -37
  21. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  22. package/dist/modules/sales/commands/documents.js +27 -0
  23. package/dist/modules/sales/commands/documents.js.map +2 -2
  24. package/dist/modules/sales/data/validators.js +2 -0
  25. package/dist/modules/sales/data/validators.js.map +2 -2
  26. package/package.json +7 -7
  27. package/src/modules/entities/api/definitions.ts +4 -0
  28. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  29. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  30. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  31. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  32. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  33. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  34. package/src/modules/query_index/api/reindex.ts +15 -0
  35. package/src/modules/query_index/cli.ts +180 -1
  36. package/src/modules/query_index/lib/coverage.ts +38 -8
  37. package/src/modules/query_index/lib/reindexer.ts +62 -40
  38. package/src/modules/sales/commands/documents.ts +49 -0
  39. package/src/modules/sales/data/validators.ts +2 -0
@@ -4,7 +4,7 @@ import { resolveExtensionPointPattern } from '@open-mercato/shared/modules/widge
4
4
  import { extensionPoints } from '@open-mercato/core/modules/entities/extension-points'
5
5
  import { useSearchParams } from 'next/navigation'
6
6
  import type { ColumnDef, SortingState } from '@tanstack/react-table'
7
- import { filterCustomFieldDefs, useCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
7
+ import { filterCustomFieldDefs, useCustomFieldDefs, type CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
8
8
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
9
9
  import { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'
10
10
  import type { PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
@@ -23,6 +23,11 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
23
23
  import { useT } from '@open-mercato/shared/lib/i18n/context'
24
24
  import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
25
25
  import { useRecordsEntityGuard } from '@open-mercato/core/modules/entities/components/useRecordsEntityGuard'
26
+ import {
27
+ RelationCell,
28
+ RelationDisplaysProvider,
29
+ useRecordListRelationDisplays,
30
+ } from '@open-mercato/core/modules/entities/components/RecordListRelationCell'
26
31
 
27
32
  type RecordsResponse = {
28
33
  items: any[]
@@ -98,6 +103,13 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
98
103
  enabled: Boolean(entityId),
99
104
  keyExtras: [scopeVersion],
100
105
  })
106
+ const relationDefinitions = React.useMemo(
107
+ () => filterCustomFieldDefs(cfDefs, 'list').filter(
108
+ (definition): definition is CustomFieldDefDto => definition.kind === 'relation',
109
+ ),
110
+ [cfDefs],
111
+ )
112
+ const relationDisplaysByField = useRecordListRelationDisplays(relationDefinitions, rawData)
101
113
 
102
114
  // Fields searched server-side: every visible column plus the base `id`.
103
115
  const searchableFields = React.useMemo(() => {
@@ -182,7 +194,10 @@ function RecordsPageInner({ params }: { params: { entityId?: string } }) {
182
194
  meta: { priority: idx < 4 ? 1 : idx < 6 ? 2 : idx < 8 ? 3 : idx < maxVisible ? 4 : 5 },
183
195
  cell: ({ getValue }: { getValue: () => unknown }) => {
184
196
  const v = getValue() as any
185
- return <span className="truncate max-w-[24ch] inline-block align-top" title={normalizeCell(v)}>{normalizeCell(v)}</span>
197
+ if (d.kind === 'relation') {
198
+ return <RelationCell fieldKey={d.key} value={v} fallbackText={normalizeCell(v)} />
199
+ }
200
+ return <span className="inline-block max-w-60 truncate align-top" title={normalizeCell(v)}>{normalizeCell(v)}</span>
186
201
  },
187
202
  }))
188
203
  // Ensure hidden 'id' column exists for sorting/state
@@ -378,81 +393,83 @@ export RECORD_ID="<record uuid>"`}</code></pre>
378
393
  </div>
379
394
  </div>
380
395
  </ContextHelp>
381
- <DataTable
382
- stickyActionsColumn
383
- title={`Records: ${entityId}`}
384
- entityId={entityId}
385
- actions={actions}
386
- columns={columns}
387
- data={data}
388
- perspective={{
389
- tableId: resolveExtensionPointPattern(extensionPoints.hosts.userRecordsTable.pattern, { entityId }),
390
- }}
391
- exporter={exportConfig}
392
- filters={baseFilters}
393
- filterValues={filterValues}
394
- rowActions={(row) => (
395
- <RowActions
396
- items={[
397
- { id: 'edit', label: 'Edit', href: `/backend/entities/user/${encodeURIComponent(entityId)}/records/${encodeURIComponent(String((row as any).id))}` },
398
- { id: 'delete', label: 'Delete', destructive: true, onSelect: async () => {
399
- const recordId = String((row as any).id)
400
- try {
401
- const confirmed = await confirm({
402
- title: 'Delete this record?',
403
- variant: 'destructive',
404
- })
405
- if (!confirmed) return
406
- await runDeleteMutation({
407
- operation: async () => {
408
- const deleteCall = await withScopedApiRequestHeaders(
409
- buildOptimisticLockHeader((row as any).updatedAt),
410
- () => apiCall(
411
- `/api/entities/records?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(recordId)}`,
412
- { method: 'DELETE' },
413
- ),
414
- )
415
- if (!deleteCall.ok) {
416
- await raiseCrudError(deleteCall.response, 'Failed to delete record')
417
- }
418
- },
419
- context: {
420
- formId: deleteMutationContextId,
421
- resourceKind: 'entities.record',
422
- resourceId: recordId,
423
- retryLastMutation: retryDeleteMutation,
424
- },
425
- })
426
- const j = await readApiResultOrThrow<RecordsResponse>(
427
- `/api/entities/records?entityId=${encodeURIComponent(entityId)}&page=${page}&pageSize=${pageSize}`,
428
- undefined,
429
- {
430
- errorMessage: 'Failed to reload records',
431
- fallback: { items: [], total: 0, page, pageSize, totalPages: 1 },
432
- },
433
- )
434
- setRawData(j.items || [])
435
- setTotal(j.total || 0)
436
- setTotalPages(j.totalPages || 1)
437
- flash('Record has been removed', 'success')
438
- } catch (error) {
439
- const message = error instanceof Error ? error.message : 'Failed to delete record'
440
- flash(message, 'error')
441
- }
442
- } },
443
- ]}
444
- />
445
- )}
446
- sortable
447
- sorting={sorting}
448
- onSortingChange={setSorting}
449
- searchValue={search}
450
- onSearchChange={(v) => { setSearch(v); setPage(1) }}
451
- onFiltersApply={(vals) => { setFilterValues(vals); setPage(1) }}
452
- onFiltersClear={() => { setFilterValues({}); setPage(1) }}
453
- pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
454
- isLoading={loading}
455
- />
396
+ <RelationDisplaysProvider displaysByField={relationDisplaysByField}>
397
+ <DataTable
398
+ stickyActionsColumn
399
+ title={`Records: ${entityId}`}
400
+ entityId={entityId}
401
+ actions={actions}
402
+ columns={columns}
403
+ data={data}
404
+ perspective={{
405
+ tableId: resolveExtensionPointPattern(extensionPoints.hosts.userRecordsTable.pattern, { entityId }),
406
+ }}
407
+ exporter={exportConfig}
408
+ filters={baseFilters}
409
+ filterValues={filterValues}
410
+ rowActions={(row) => (
411
+ <RowActions
412
+ items={[
413
+ { id: 'edit', label: 'Edit', href: `/backend/entities/user/${encodeURIComponent(entityId)}/records/${encodeURIComponent(String((row as any).id))}` },
414
+ { id: 'delete', label: 'Delete', destructive: true, onSelect: async () => {
415
+ const recordId = String((row as any).id)
416
+ try {
417
+ const confirmed = await confirm({
418
+ title: 'Delete this record?',
419
+ variant: 'destructive',
420
+ })
421
+ if (!confirmed) return
422
+ await runDeleteMutation({
423
+ operation: async () => {
424
+ const deleteCall = await withScopedApiRequestHeaders(
425
+ buildOptimisticLockHeader((row as any).updatedAt),
426
+ () => apiCall(
427
+ `/api/entities/records?entityId=${encodeURIComponent(entityId)}&recordId=${encodeURIComponent(recordId)}`,
428
+ { method: 'DELETE' },
429
+ ),
430
+ )
431
+ if (!deleteCall.ok) {
432
+ await raiseCrudError(deleteCall.response, 'Failed to delete record')
433
+ }
434
+ },
435
+ context: {
436
+ formId: deleteMutationContextId,
437
+ resourceKind: 'entities.record',
438
+ resourceId: recordId,
439
+ retryLastMutation: retryDeleteMutation,
440
+ },
441
+ })
442
+ const j = await readApiResultOrThrow<RecordsResponse>(
443
+ `/api/entities/records?entityId=${encodeURIComponent(entityId)}&page=${page}&pageSize=${pageSize}`,
444
+ undefined,
445
+ {
446
+ errorMessage: 'Failed to reload records',
447
+ fallback: { items: [], total: 0, page, pageSize, totalPages: 1 },
448
+ },
449
+ )
450
+ setRawData(j.items || [])
451
+ setTotal(j.total || 0)
452
+ setTotalPages(j.totalPages || 1)
453
+ flash('Record has been removed', 'success')
454
+ } catch (error) {
455
+ const message = error instanceof Error ? error.message : 'Failed to delete record'
456
+ flash(message, 'error')
457
+ }
458
+ } },
459
+ ]}
460
+ />
461
+ )}
462
+ sortable
463
+ sorting={sorting}
464
+ onSortingChange={setSorting}
465
+ searchValue={search}
466
+ onSearchChange={(v) => { setSearch(v); setPage(1) }}
467
+ onFiltersApply={(vals) => { setFilterValues(vals); setPage(1) }}
468
+ onFiltersClear={() => { setFilterValues({}); setPage(1) }}
469
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
470
+ isLoading={loading}
471
+ />
472
+ </RelationDisplaysProvider>
456
473
  </PageBody>
457
474
  {ConfirmDialogElement}
458
475
  </Page>
@@ -0,0 +1,90 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
5
+ import {
6
+ getRecordListRelationDisplays,
7
+ resolveRecordListRelationDisplays,
8
+ type RelationDisplaysByField,
9
+ } from './recordListRelationDisplays'
10
+
11
+ const RelationDisplaysContext = React.createContext<RelationDisplaysByField>({})
12
+
13
+ export function RelationDisplaysProvider({
14
+ displaysByField,
15
+ children,
16
+ }: {
17
+ displaysByField: RelationDisplaysByField
18
+ children: React.ReactNode
19
+ }) {
20
+ return (
21
+ <RelationDisplaysContext.Provider value={displaysByField}>
22
+ {children}
23
+ </RelationDisplaysContext.Provider>
24
+ )
25
+ }
26
+
27
+ export function useRecordListRelationDisplays(
28
+ definitions: CustomFieldDefDto[],
29
+ records: Array<Record<string, unknown>>,
30
+ ): RelationDisplaysByField {
31
+ const [displaysByField, setDisplaysByField] = React.useState<RelationDisplaysByField>({})
32
+
33
+ React.useEffect(() => {
34
+ const abortController = new AbortController()
35
+ setDisplaysByField((current) => (Object.keys(current).length ? {} : current))
36
+ if (!definitions.length || !records.length) {
37
+ return () => abortController.abort()
38
+ }
39
+
40
+ void resolveRecordListRelationDisplays(definitions, records, abortController.signal)
41
+ .then((resolvedDisplays) => {
42
+ if (!abortController.signal.aborted) setDisplaysByField(resolvedDisplays)
43
+ })
44
+ .catch(() => {
45
+ if (!abortController.signal.aborted) setDisplaysByField({})
46
+ })
47
+
48
+ return () => abortController.abort()
49
+ }, [definitions, records])
50
+
51
+ return displaysByField
52
+ }
53
+
54
+ export function RelationCell({
55
+ fieldKey,
56
+ value,
57
+ fallbackText,
58
+ }: {
59
+ fieldKey: string
60
+ value: unknown
61
+ fallbackText: string
62
+ }) {
63
+ const displaysByField = React.useContext(RelationDisplaysContext)
64
+ const resolvedDisplays = getRecordListRelationDisplays(value, displaysByField[fieldKey])
65
+ const rawValue = resolvedDisplays.map((display) => display.id).join(', ') || fallbackText
66
+
67
+ if (!resolvedDisplays.length) {
68
+ return <span className="inline-block max-w-60 truncate align-top" title={rawValue}>{rawValue}</span>
69
+ }
70
+
71
+ return (
72
+ <span className="inline-block max-w-60 truncate align-top" title={rawValue}>
73
+ {resolvedDisplays.map((display, index) => (
74
+ <React.Fragment key={`${display.id}-${index}`}>
75
+ {index > 0 ? ', ' : null}
76
+ {display.href ? (
77
+ <Link
78
+ href={display.href}
79
+ className="font-medium text-primary underline-offset-2 hover:underline focus-visible:underline"
80
+ onClick={(event) => event.stopPropagation()}
81
+ onKeyDown={(event) => event.stopPropagation()}
82
+ >
83
+ {display.label}
84
+ </Link>
85
+ ) : display.label}
86
+ </React.Fragment>
87
+ ))}
88
+ </span>
89
+ )
90
+ }
@@ -0,0 +1,109 @@
1
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
2
+ import {
3
+ buildRelationHref,
4
+ collectRelationValueIds,
5
+ fetchRelationRecordDisplays,
6
+ parseRelationOptionsMetadata,
7
+ type RelationOptionsMetadata,
8
+ type ResolvedValueDisplay,
9
+ } from '@open-mercato/ui/backend/utils/customFieldRelationDisplay'
10
+
11
+ export type RelationDisplaysByField = Record<string, Record<string, ResolvedValueDisplay>>
12
+
13
+ type RelationLookup = {
14
+ optionsUrl: string
15
+ relation: RelationOptionsMetadata
16
+ recordIds: Set<string>
17
+ recordIdsByField: Map<string, Set<string>>
18
+ }
19
+
20
+ export function resolveRelationOptionsUrl(definition: CustomFieldDefDto): string | null {
21
+ if (definition.kind !== 'relation') return null
22
+
23
+ const configuredUrl = definition.optionsUrl?.trim()
24
+ if (configuredUrl && parseRelationOptionsMetadata(configuredUrl)) return configuredUrl
25
+
26
+ const relatedEntityId = definition.relatedEntityId?.trim()
27
+ if (!relatedEntityId) return null
28
+ return `/api/entities/relations/options?entityId=${encodeURIComponent(relatedEntityId)}`
29
+ }
30
+
31
+ function collectRelationLookups(
32
+ definitions: CustomFieldDefDto[],
33
+ records: Array<Record<string, unknown>>,
34
+ ): RelationLookup[] {
35
+ const lookups = new Map<string, RelationLookup>()
36
+
37
+ definitions.forEach((definition) => {
38
+ const optionsUrl = resolveRelationOptionsUrl(definition)
39
+ if (!optionsUrl) return
40
+ const relation = parseRelationOptionsMetadata(optionsUrl)
41
+ if (!relation) return
42
+
43
+ const recordIds = new Set(
44
+ records.flatMap((record) => collectRelationValueIds(record[definition.key])),
45
+ )
46
+ if (!recordIds.size) return
47
+
48
+ const lookupKey = JSON.stringify([relation.entityId, optionsUrl])
49
+ const lookup = lookups.get(lookupKey) ?? {
50
+ optionsUrl,
51
+ relation,
52
+ recordIds: new Set<string>(),
53
+ recordIdsByField: new Map<string, Set<string>>(),
54
+ }
55
+ recordIds.forEach((recordId) => lookup.recordIds.add(recordId))
56
+ lookup.recordIdsByField.set(definition.key, recordIds)
57
+ lookups.set(lookupKey, lookup)
58
+ })
59
+
60
+ return Array.from(lookups.values())
61
+ }
62
+
63
+ export async function resolveRecordListRelationDisplays(
64
+ definitions: CustomFieldDefDto[],
65
+ records: Array<Record<string, unknown>>,
66
+ signal?: AbortSignal,
67
+ ): Promise<RelationDisplaysByField> {
68
+ const displaysByField: RelationDisplaysByField = {}
69
+ const lookups = collectRelationLookups(definitions, records)
70
+
71
+ await Promise.all(lookups.map(async (lookup) => {
72
+ let fetchedDisplays: Record<string, ResolvedValueDisplay> = {}
73
+ try {
74
+ fetchedDisplays = await fetchRelationRecordDisplays(
75
+ lookup.optionsUrl,
76
+ lookup.relation,
77
+ Array.from(lookup.recordIds),
78
+ signal,
79
+ )
80
+ } catch {
81
+ fetchedDisplays = {}
82
+ }
83
+
84
+ if (signal?.aborted) return
85
+ lookup.recordIdsByField.forEach((recordIds, fieldKey) => {
86
+ const fieldDisplays: Record<string, ResolvedValueDisplay> = {}
87
+ recordIds.forEach((recordId) => {
88
+ fieldDisplays[recordId] = fetchedDisplays[recordId] ?? {
89
+ label: recordId,
90
+ href: buildRelationHref(lookup.relation.entityId, recordId),
91
+ }
92
+ })
93
+ displaysByField[fieldKey] = fieldDisplays
94
+ })
95
+ }))
96
+
97
+ return displaysByField
98
+ }
99
+
100
+ export function getRecordListRelationDisplays(
101
+ value: unknown,
102
+ displays: Record<string, ResolvedValueDisplay> | undefined,
103
+ ): Array<{ id: string; label: string; href?: string }> {
104
+ return collectRelationValueIds(value).map((recordId) => ({
105
+ id: recordId,
106
+ label: displays?.[recordId]?.label ?? recordId,
107
+ href: displays?.[recordId]?.href,
108
+ }))
109
+ }
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
6
  import {
5
7
  runCrudMutationGuardAfterSuccess,
6
8
  validateCrudMutationGuard,
@@ -40,6 +42,20 @@ export async function PATCH(req: Request) {
40
42
  return NextResponse.json({ error: 'Action already processed' }, { status: 409 })
41
43
  }
42
44
 
45
+ // Optimistic lock: refuse a stale overwrite when two tabs edit the same action
46
+ // payload. Strictly additive — a no-op without the expected-version header.
47
+ try {
48
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
49
+ resourceKind: 'inbox_ops.action',
50
+ resourceId: action.id,
51
+ current: action.updatedAt ?? null,
52
+ request: req,
53
+ })
54
+ } catch (err) {
55
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
56
+ throw err
57
+ }
58
+
43
59
  const mergedPayload = { ...action.payload as Record<string, unknown>, ...parsed.data.payload }
44
60
  const payloadValidation = validateActionPayloadForType(action.actionType, mergedPayload)
45
61
  if (!payloadValidation.success) {
@@ -108,7 +124,7 @@ export const openApi: OpenApiRouteDoc = {
108
124
  responses: [
109
125
  { status: 200, description: 'Action updated' },
110
126
  { status: 404, description: 'Action not found' },
111
- { status: 409, description: 'Action already processed' },
127
+ { status: 409, description: 'Action already processed or stale (optimistic-lock conflict)' },
112
128
  ],
113
129
  },
114
130
  },
@@ -9,8 +9,8 @@ import {
9
9
  SelectTrigger,
10
10
  SelectValue,
11
11
  } from '@open-mercato/ui/primitives/select'
12
- import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
- import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
12
+ import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
13
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
14
14
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
16
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -461,25 +461,30 @@ export function EditActionDialog({
461
461
  }
462
462
 
463
463
  setIsSaving(true)
464
- const result = await runMutation({
465
- // TODO(#2373-D): thread updatedAt — ActionDetail type lives outside this file's edit scope
466
- operation: () => withScopedApiRequestHeaders(
467
- buildOptimisticLockHeader(undefined),
468
- () => apiCall<{ ok: boolean; error?: string }>(
469
- `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
470
- { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
464
+ try {
465
+ await runMutation({
466
+ operation: () => withScopedApiRequestHeaders(
467
+ buildOptimisticLockHeader(action.updatedAt),
468
+ () => apiCallOrThrow<{ ok: boolean; error?: string }>(
469
+ `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
470
+ { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
471
+ ),
471
472
  ),
472
- ),
473
- context: {},
474
- })
475
- if (result?.ok && result.result?.ok) {
473
+ context: {},
474
+ })
476
475
  flash(t('inbox_ops.edit_dialog.saved', 'Action updated successfully'), 'success')
477
476
  onSaved()
478
477
  onClose()
479
- } else {
480
- flash(result?.result?.error || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
478
+ } catch (error) {
479
+ if (extractOptimisticLockConflict(error)) {
480
+ onClose()
481
+ return
482
+ }
483
+ const message = error instanceof Error ? error.message.trim() : ''
484
+ flash(message || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
485
+ } finally {
486
+ setIsSaving(false)
481
487
  }
482
- setIsSaving(false)
483
488
  }, [action, payload, jsonMode, jsonText, t, onSaved, onClose, runMutation])
484
489
 
485
490
  React.useEffect(() => {
@@ -27,6 +27,7 @@ export type ActionDetail = {
27
27
  createdEntityType?: string
28
28
  executionError?: string
29
29
  executedAt?: string
30
+ updatedAt?: string
30
31
  }
31
32
 
32
33
  export type DiscrepancyDetail = {
@@ -85,6 +85,21 @@ export async function POST(req: Request) {
85
85
  },
86
86
  },
87
87
  ).catch(() => undefined)
88
+ // Queued once here rather than from a partition: the purge deletes every vector for the
89
+ // scope, so emitting it from inside the fan-out would race the per-record vectorize jobs
90
+ // its siblings are already queueing and drop vectors this run had just rebuilt.
91
+ if (force && partitions.length > 1) {
92
+ try {
93
+ await bus.emitEvent(
94
+ 'query_index.vectorize_purge',
95
+ { entityType, tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
96
+ { persistent: true, deliverInline: false },
97
+ )
98
+ } catch {
99
+ // Best effort: a failed purge leaves stale vectors, which the orphan sweep still prunes.
100
+ }
101
+ }
102
+
88
103
  try {
89
104
  await Promise.all(
90
105
  partitions.map((part) => {