@open-mercato/enterprise 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6331.1.a33b8e99b7
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/record_locks/di.js +23 -1
- package/dist/modules/record_locks/di.js.map +2 -2
- package/dist/modules/record_locks/lib/crudMutationGuardService.js +48 -18
- package/dist/modules/record_locks/lib/crudMutationGuardService.js.map +2 -2
- package/dist/modules/record_locks/lib/recordLockService.js +16 -0
- package/dist/modules/record_locks/lib/recordLockService.js.map +2 -2
- package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js +74 -35
- package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js.map +3 -3
- package/package.json +5 -5
- package/src/modules/record_locks/di.ts +28 -2
- package/src/modules/record_locks/lib/crudMutationGuardService.ts +88 -17
- package/src/modules/record_locks/lib/recordLockService.ts +25 -0
- package/src/modules/record_locks/widgets/injection/record-locking/widget.client.tsx +119 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/enterprise",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
69
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
68
|
+
"@open-mercato/core": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
69
|
+
"@open-mercato/ui": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
70
70
|
"@simplewebauthn/browser": "^13.3.0",
|
|
71
71
|
"@simplewebauthn/server": "^13.3.1",
|
|
72
72
|
"@simplewebauthn/types": "^12.0.0",
|
|
@@ -76,12 +76,12 @@
|
|
|
76
76
|
"qrcode": "^1.5.4"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
80
80
|
"react": "^19.0.0",
|
|
81
81
|
"react-dom": "^19.0.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
84
|
+
"@open-mercato/shared": "0.6.6-develop.6331.1.a33b8e99b7",
|
|
85
85
|
"@types/jest": "^30.0.0",
|
|
86
86
|
"@types/react": "^19.2.17",
|
|
87
87
|
"@types/react-dom": "^19.2.3",
|
|
@@ -4,6 +4,9 @@ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
5
5
|
import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
|
|
6
6
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
|
+
import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
8
|
+
import { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
9
|
+
import { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
7
10
|
import { createRecordLockService } from './lib/recordLockService'
|
|
8
11
|
import type { RecordLockService } from './lib/recordLockService'
|
|
9
12
|
import { createRecordLockCrudMutationGuardService } from './lib/crudMutationGuardService'
|
|
@@ -23,8 +26,31 @@ export function register(container: AppContainer) {
|
|
|
23
26
|
rbacService: rbacService ?? null,
|
|
24
27
|
}),
|
|
25
28
|
).scoped(),
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
// CRUD guard decorator: chains the OSS `updated_at` floor first (built here
|
|
30
|
+
// because this DI key overrides the platform default), then adds the
|
|
31
|
+
// record_locks enrichment. record_locks can only ADD a 409, never skip the
|
|
32
|
+
// floor (S1/H2). Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)
|
|
33
|
+
crudMutationGuardService: asFunction((
|
|
34
|
+
recordLockService: RecordLockService,
|
|
35
|
+
em: EntityManager,
|
|
36
|
+
) =>
|
|
37
|
+
createRecordLockCrudMutationGuardService(
|
|
38
|
+
recordLockService,
|
|
39
|
+
createOptimisticLockGuardService({
|
|
40
|
+
getEm: () => em,
|
|
41
|
+
readers: getAllOptimisticLockReaders(),
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
).scoped(),
|
|
45
|
+
// Command guard override: lock-backed `resolveExpected` derived from
|
|
46
|
+
// authoritative server state (never requiring a client lock token, H2),
|
|
47
|
+
// awaited by `enforceCommandOptimisticLockWithGuards`. The OSS floor still
|
|
48
|
+
// runs first inside that runner.
|
|
49
|
+
commandOptimisticLockGuardService: asFunction((recordLockService: RecordLockService) =>
|
|
50
|
+
createCommandOptimisticLockGuardService({
|
|
51
|
+
resolveExpected: ({ expectedFromHeader, resourceKind }) =>
|
|
52
|
+
recordLockService.resolveExpectedVersion({ expectedFromHeader, resourceKind }),
|
|
53
|
+
}),
|
|
28
54
|
).scoped(),
|
|
29
55
|
})
|
|
30
56
|
}
|
|
@@ -3,50 +3,121 @@ import type {
|
|
|
3
3
|
CrudMutationGuardValidateInput,
|
|
4
4
|
CrudMutationGuardValidationResult,
|
|
5
5
|
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
6
|
+
import {
|
|
7
|
+
parseOptimisticLockEnv,
|
|
8
|
+
type OptimisticLockConfig,
|
|
9
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock'
|
|
10
|
+
import { OPTIMISTIC_LOCK_ENV_VAR } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
6
11
|
import { readRecordLockHeaders, type RecordLockService } from './recordLockService'
|
|
12
|
+
import { isRecordLockingEnabledForResource } from './config'
|
|
7
13
|
|
|
8
14
|
export type RecordLockCrudMutationGuardService = {
|
|
9
15
|
validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
|
|
10
16
|
afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
|
|
11
17
|
}
|
|
12
18
|
|
|
19
|
+
/** The OSS floor service this decorator chains. Same shape as the platform default. */
|
|
20
|
+
export type OssCrudMutationGuardServiceLike = {
|
|
21
|
+
validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>
|
|
22
|
+
afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
function resolveRecordLockMutationMethod(operation: CrudMutationGuardValidateInput['operation']): 'PUT' | 'DELETE' {
|
|
14
26
|
if (operation === 'delete') return 'DELETE'
|
|
15
27
|
return 'PUT'
|
|
16
28
|
}
|
|
17
29
|
|
|
30
|
+
function resolveConfig(envValue: string | null | undefined): OptimisticLockConfig {
|
|
31
|
+
return parseOptimisticLockEnv(envValue !== undefined ? envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CreateRecordLockCrudMutationGuardServiceOptions = {
|
|
35
|
+
/** Override `OM_OPTIMISTIC_LOCK` (mostly for tests). */
|
|
36
|
+
envValue?: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Enterprise CRUD mutation guard. A **decorator** over the OSS optimistic-lock
|
|
41
|
+
* floor (S1/H2): it never replaces the floor, only adds blocks.
|
|
42
|
+
*
|
|
43
|
+
* Evaluation order (matches the spec's guard-layering chain):
|
|
44
|
+
* 1. `OM_OPTIMISTIC_LOCK=off` → pure pass-through (single global kill switch;
|
|
45
|
+
* neither floor nor enrichment runs).
|
|
46
|
+
* 2. OSS `updated_at` floor runs first (delegated to the default OSS guard the
|
|
47
|
+
* enterprise registration overrides). A stale write 409s here regardless of
|
|
48
|
+
* any record-lock token / widget state — so a tokenless API/CLI client is
|
|
49
|
+
* still caught (H1/H2).
|
|
50
|
+
* 3. Only if the floor passes, AND record_locks is enabled for the resource in
|
|
51
|
+
* settings, the enterprise `validateMutation` enrichment runs (pessimistic
|
|
52
|
+
* lock + action-log diff). It can only ADD a 409, never relax the floor.
|
|
53
|
+
*
|
|
54
|
+
* Fail-closed (H3): if the enterprise enrichment throws, the decorator degrades
|
|
55
|
+
* to the floor result (floor-pass ⇒ allow), never to "skip the guard".
|
|
56
|
+
*/
|
|
18
57
|
export function createRecordLockCrudMutationGuardService(
|
|
19
58
|
recordLockService: RecordLockService,
|
|
59
|
+
ossFloorGuardService: OssCrudMutationGuardServiceLike,
|
|
60
|
+
options: CreateRecordLockCrudMutationGuardServiceOptions = {},
|
|
20
61
|
): RecordLockCrudMutationGuardService {
|
|
62
|
+
async function isRecordLockEnrichmentEnabled(resourceKind: string): Promise<boolean> {
|
|
63
|
+
try {
|
|
64
|
+
const settings = await recordLockService.getSettings()
|
|
65
|
+
return isRecordLockingEnabledForResource(settings, resourceKind)
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
21
71
|
return {
|
|
22
72
|
async validateMutation(input) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
73
|
+
const config = resolveConfig(options.envValue)
|
|
74
|
+
if (config.mode === 'off') {
|
|
75
|
+
// Single global kill switch: neither floor nor enrichment runs.
|
|
76
|
+
return { ok: true, shouldRunAfterSuccess: false, metadata: null }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 1. OSS floor — always runs first, independent of any client lock token.
|
|
80
|
+
const floorResult = await ossFloorGuardService.validateMutation(input)
|
|
81
|
+
if (!floorResult.ok) return floorResult
|
|
82
|
+
|
|
83
|
+
// 2. Enterprise enrichment — only when the resource is enabled in settings.
|
|
84
|
+
if (!(await isRecordLockEnrichmentEnabled(input.resourceKind))) {
|
|
85
|
+
return { ok: true, shouldRunAfterSuccess: false, metadata: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Fail-closed: a throwing enrichment degrades to the (passed) floor.
|
|
89
|
+
let enrichmentResult: Awaited<ReturnType<RecordLockService['validateMutation']>>
|
|
90
|
+
try {
|
|
91
|
+
enrichmentResult = await recordLockService.validateMutation({
|
|
92
|
+
tenantId: input.tenantId,
|
|
93
|
+
organizationId: input.organizationId ?? null,
|
|
94
|
+
userId: input.userId,
|
|
95
|
+
resourceKind: input.resourceKind,
|
|
96
|
+
resourceId: input.resourceId,
|
|
97
|
+
method: resolveRecordLockMutationMethod(input.operation),
|
|
98
|
+
headers: readRecordLockHeaders(input.requestHeaders),
|
|
99
|
+
mutationPayload: input.mutationPayload ?? null,
|
|
100
|
+
})
|
|
101
|
+
} catch {
|
|
102
|
+
return { ok: true, shouldRunAfterSuccess: false, metadata: null }
|
|
103
|
+
}
|
|
33
104
|
|
|
34
|
-
if (
|
|
105
|
+
if (enrichmentResult.ok) {
|
|
35
106
|
return {
|
|
36
107
|
ok: true,
|
|
37
|
-
shouldRunAfterSuccess:
|
|
108
|
+
shouldRunAfterSuccess: enrichmentResult.resourceEnabled,
|
|
38
109
|
metadata: null,
|
|
39
110
|
}
|
|
40
111
|
}
|
|
41
112
|
|
|
42
113
|
return {
|
|
43
114
|
ok: false,
|
|
44
|
-
status:
|
|
115
|
+
status: enrichmentResult.status,
|
|
45
116
|
body: {
|
|
46
|
-
error:
|
|
47
|
-
code:
|
|
48
|
-
lock:
|
|
49
|
-
conflict:
|
|
117
|
+
error: enrichmentResult.error,
|
|
118
|
+
code: enrichmentResult.code,
|
|
119
|
+
lock: enrichmentResult.lock ?? null,
|
|
120
|
+
conflict: enrichmentResult.conflict ?? null,
|
|
50
121
|
},
|
|
51
122
|
}
|
|
52
123
|
},
|
|
@@ -491,6 +491,31 @@ export class RecordLockService {
|
|
|
491
491
|
return settings // NOSONAR — both paths return settings by design; the branch controls persistence
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Command-guard `resolveExpected` seam (Phase 0 / S1). Derives the expected
|
|
496
|
+
* version token the OSS command floor compares against the record's current
|
|
497
|
+
* server-side `updated_at`.
|
|
498
|
+
*
|
|
499
|
+
* The compare is server-authoritative: the command handler already loaded the
|
|
500
|
+
* record from the DB and passes its `updated_at` as `current`, so a stale
|
|
501
|
+
* write is detected from the version header alone — NEVER requiring a client
|
|
502
|
+
* record-lock token (H2). When record_locks is not enabled for the resource
|
|
503
|
+
* (settings off), this returns the header token unchanged so the floor still
|
|
504
|
+
* runs (pure floor behavior). The richer pessimistic/action-log conflict
|
|
505
|
+
* detection runs through `validateMutation` at the CRUD layer.
|
|
506
|
+
*/
|
|
507
|
+
async resolveExpectedVersion(input: {
|
|
508
|
+
resourceKind: string
|
|
509
|
+
expectedFromHeader: string | null
|
|
510
|
+
}): Promise<string | null> {
|
|
511
|
+
// Enabled or not, the server-authoritative floor compares the header token
|
|
512
|
+
// against the DB `updated_at`, so resolution is unconditionally the header
|
|
513
|
+
// value. Gating (record_locks settings) only changes whether the richer
|
|
514
|
+
// CRUD-layer conflict enrichment runs in `validateMutation` — it never
|
|
515
|
+
// affects the version floor here.
|
|
516
|
+
return input.expectedFromHeader
|
|
517
|
+
}
|
|
518
|
+
|
|
494
519
|
async acquire(input: RecordLockAcquireInput): Promise<RecordLockAcquireResult | RecordLockAcquireFailure> {
|
|
495
520
|
this.scheduleCleanup(input.tenantId)
|
|
496
521
|
const settings = await this.getSettings()
|
|
@@ -10,6 +10,7 @@ import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
|
|
|
10
10
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
11
|
import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
|
|
12
12
|
import { BACKEND_MUTATION_ERROR_EVENT } from '@open-mercato/ui/backend/injection/mutationEvents'
|
|
13
|
+
import { registerRecordLockConflictHandler } from '@open-mercato/ui/backend/conflicts'
|
|
13
14
|
import { useSearchParams } from 'next/navigation'
|
|
14
15
|
import { Mail } from 'lucide-react'
|
|
15
16
|
import {
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
type RecordLockUiView,
|
|
33
34
|
} from '@open-mercato/enterprise/modules/record_locks/lib/clientLockStore'
|
|
34
35
|
import { isUuid, resolveConflictId, runAcceptIncoming } from './conflictResolution'
|
|
35
|
-
import {
|
|
36
|
+
import { isOptimisticLockFloorConflict } from '@open-mercato/enterprise/modules/record_locks/lib/optimisticLockFloor'
|
|
36
37
|
|
|
37
38
|
type CrudInjectionContext = {
|
|
38
39
|
formId?: string
|
|
@@ -295,6 +296,74 @@ function isRecordDeletedError(error: unknown): boolean {
|
|
|
295
296
|
return false
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Action the record-lock widget takes when a CrudForm / guarded-mutation save
|
|
301
|
+
* error event (`om:crud-save-error` / backend-mutation-error) fires. Extracted
|
|
302
|
+
* as a pure function so the single-conflict-surface (S3) decision is unit-testable
|
|
303
|
+
* without rendering the widget:
|
|
304
|
+
*
|
|
305
|
+
* - `ignore` — event is for a different form/record, there is no
|
|
306
|
+
* actionable mounted record, OR the error is a plain OSS
|
|
307
|
+
* `optimistic_lock_conflict` 409 already owned by the OSS
|
|
308
|
+
* conflict bar via `surfaceRecordConflict` (opening the
|
|
309
|
+
* dialog too would render TWO surfaces — #3504/#3505).
|
|
310
|
+
* - `record-deleted` — the record was deleted by another user.
|
|
311
|
+
* - `apply-conflict` — a genuine enterprise `record_lock_conflict` payload;
|
|
312
|
+
* open the field-level merge dialog with it.
|
|
313
|
+
* - `fallback-dialog` — an unrecognized 409 (neither record-lock nor OSS
|
|
314
|
+
* optimistic-lock) for the mounted record; open the
|
|
315
|
+
* degraded fallback dialog (legacy behavior).
|
|
316
|
+
*/
|
|
317
|
+
export type RecordLockSaveErrorDecision =
|
|
318
|
+
| { action: 'ignore' }
|
|
319
|
+
| { action: 'record-deleted' }
|
|
320
|
+
| {
|
|
321
|
+
action: 'apply-conflict'
|
|
322
|
+
payload: {
|
|
323
|
+
conflict: RecordLockUiConflict
|
|
324
|
+
lock?: RecordLockUiView | null
|
|
325
|
+
latestActionLogId?: string | null
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
| { action: 'fallback-dialog' }
|
|
329
|
+
|
|
330
|
+
export function resolveRecordLockSaveErrorDecision(args: {
|
|
331
|
+
error: unknown
|
|
332
|
+
eventContextId: string | null | undefined
|
|
333
|
+
formId: string
|
|
334
|
+
currentState: { resourceKind?: string | null; resourceId?: string | null } | null | undefined
|
|
335
|
+
}): RecordLockSaveErrorDecision {
|
|
336
|
+
const { error, eventContextId, formId, currentState } = args
|
|
337
|
+
const payload = extractRecordLockConflictPayload(error)
|
|
338
|
+
const eventTargetsCurrentForm = !eventContextId || eventContextId === formId
|
|
339
|
+
if (!eventTargetsCurrentForm) {
|
|
340
|
+
if (!payload || !currentState?.resourceKind || !currentState?.resourceId) return { action: 'ignore' }
|
|
341
|
+
const payloadResourceKind = payload.conflict.resourceKind?.trim() ?? ''
|
|
342
|
+
const payloadResourceId = payload.conflict.resourceId?.trim() ?? ''
|
|
343
|
+
if (!payloadResourceKind || !payloadResourceId) return { action: 'ignore' }
|
|
344
|
+
if (payloadResourceKind !== currentState.resourceKind || payloadResourceId !== currentState.resourceId) {
|
|
345
|
+
return { action: 'ignore' }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!payload) {
|
|
349
|
+
if (!currentState?.resourceKind || !currentState?.resourceId) return { action: 'ignore' }
|
|
350
|
+
if (isRecordDeletedError(error)) return { action: 'record-deleted' }
|
|
351
|
+
// A plain OSS `optimistic_lock_conflict` 409 (the shape `makeCrudRoute` /
|
|
352
|
+
// `CrudForm` returns on a stale write) is already surfaced by the OSS conflict
|
|
353
|
+
// bar via `surfaceRecordConflict`. Defer to it instead of opening the degraded
|
|
354
|
+
// fallback merge dialog — otherwise both surfaces render at once (#3504) and the
|
|
355
|
+
// dialog has no field diff and a no-op "Accept incoming" (#3505). The rich merge
|
|
356
|
+
// dialog stays reserved for genuine `record_lock_conflict` payloads (handled
|
|
357
|
+
// above) whose conflict carries field changes + resolution options. Delegates to
|
|
358
|
+
// the shared `optimisticLockFloor` detector so this arbitration stays identical to
|
|
359
|
+
// the conflict bar's ownership decision (single source of truth).
|
|
360
|
+
if (isOptimisticLockFloorConflict(error)) return { action: 'ignore' }
|
|
361
|
+
if (extractErrorStatus(error) === 409) return { action: 'fallback-dialog' }
|
|
362
|
+
return { action: 'ignore' }
|
|
363
|
+
}
|
|
364
|
+
return { action: 'apply-conflict', payload }
|
|
365
|
+
}
|
|
366
|
+
|
|
298
367
|
function clearIncomingChangesQueryFlag() {
|
|
299
368
|
if (typeof window === 'undefined') return
|
|
300
369
|
try {
|
|
@@ -1019,41 +1088,33 @@ export default function RecordLockingWidget({
|
|
|
1019
1088
|
const detail = (event as CustomEvent<CrudSaveErrorEventDetail>).detail
|
|
1020
1089
|
if (!detail) return
|
|
1021
1090
|
const eventContextId = detail.contextId ?? detail.formId
|
|
1022
|
-
let payload = extractRecordLockConflictPayload(detail.error)
|
|
1023
1091
|
const currentState = getRecordLockFormState(formId)
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1092
|
+
const decision = resolveRecordLockSaveErrorDecision({
|
|
1093
|
+
error: detail.error,
|
|
1094
|
+
eventContextId,
|
|
1095
|
+
formId,
|
|
1096
|
+
currentState,
|
|
1097
|
+
})
|
|
1098
|
+
if (decision.action === 'ignore') return
|
|
1099
|
+
if (decision.action === 'record-deleted') {
|
|
1100
|
+
setIsConflictDialogOpen(true)
|
|
1101
|
+
setRecordLockFormState(formId, {
|
|
1102
|
+
recordDeleted: true,
|
|
1103
|
+
acquired: false,
|
|
1104
|
+
lock: null,
|
|
1105
|
+
conflict: null,
|
|
1106
|
+
pendingConflictId: null,
|
|
1107
|
+
pendingResolution: 'normal',
|
|
1108
|
+
pendingResolutionArmed: false,
|
|
1109
|
+
})
|
|
1110
|
+
return
|
|
1031
1111
|
}
|
|
1032
|
-
|
|
1033
|
-
if (
|
|
1034
|
-
if (isRecordDeletedError(detail.error)) {
|
|
1035
|
-
setIsConflictDialogOpen(true)
|
|
1036
|
-
setRecordLockFormState(formId, {
|
|
1037
|
-
recordDeleted: true,
|
|
1038
|
-
acquired: false,
|
|
1039
|
-
lock: null,
|
|
1040
|
-
conflict: null,
|
|
1041
|
-
pendingConflictId: null,
|
|
1042
|
-
pendingResolution: 'normal',
|
|
1043
|
-
pendingResolutionArmed: false,
|
|
1044
|
-
})
|
|
1045
|
-
return
|
|
1046
|
-
}
|
|
1047
|
-
// Single-surface arbitration (issue #3504 / S3): an OSS optimistic-lock-floor
|
|
1048
|
-
// 409 is already owned by the shared conflict bar, so defer to it instead of
|
|
1049
|
-
// opening a second, degraded merge dialog. Other 409s keep the fallback dialog.
|
|
1050
|
-
if (classifyUnmatchedSaveError(detail.error, extractErrorStatus(detail.error)) === 'fallback-merge-dialog') {
|
|
1051
|
-
applyConflictPayload(buildFallbackConflict(currentState))
|
|
1052
|
-
}
|
|
1112
|
+
if (decision.action === 'fallback-dialog') {
|
|
1113
|
+
if (currentState) applyConflictPayload(buildFallbackConflict(currentState))
|
|
1053
1114
|
return
|
|
1054
1115
|
}
|
|
1055
1116
|
|
|
1056
|
-
applyConflictPayload(payload)
|
|
1117
|
+
applyConflictPayload(decision.payload)
|
|
1057
1118
|
}
|
|
1058
1119
|
|
|
1059
1120
|
window.addEventListener(BACKEND_MUTATION_ERROR_EVENT, onCrudSaveError)
|
|
@@ -1064,6 +1125,33 @@ export default function RecordLockingWidget({
|
|
|
1064
1125
|
}
|
|
1065
1126
|
}, [formId, isPrimaryInstance])
|
|
1066
1127
|
|
|
1128
|
+
// Single conflict surface (S3): own the surface for record_lock_conflict 409s
|
|
1129
|
+
// routed through `surfaceRecordConflict` (command/raw-write paths that don't
|
|
1130
|
+
// emit the crud-save-error event). Opening the dialog here is idempotent with
|
|
1131
|
+
// the listener above; returning true tells the core helper to suppress the OSS
|
|
1132
|
+
// bar (so we never render both). Decline (false) for a different record so the
|
|
1133
|
+
// bar still renders there — a conflict is never swallowed.
|
|
1134
|
+
React.useEffect(() => {
|
|
1135
|
+
if (!isPrimaryInstance) return
|
|
1136
|
+
if (!resourceKind || !resourceId) return
|
|
1137
|
+
const unregister = registerRecordLockConflictHandler((_conflict, error) => {
|
|
1138
|
+
const payload = extractRecordLockConflictPayload(error)
|
|
1139
|
+
if (!payload) return false
|
|
1140
|
+
const payloadKind = payload.conflict.resourceKind?.trim() ?? ''
|
|
1141
|
+
const payloadId = payload.conflict.resourceId?.trim() ?? ''
|
|
1142
|
+
if (payloadKind && payloadId && (payloadKind !== resourceKind || payloadId !== resourceId)) return false
|
|
1143
|
+
setIsConflictDialogOpen(true)
|
|
1144
|
+
setRecordLockFormState(formId, {
|
|
1145
|
+
conflict: payload.conflict,
|
|
1146
|
+
pendingConflictId: payload.conflict.id,
|
|
1147
|
+
pendingResolution: 'normal',
|
|
1148
|
+
pendingResolutionArmed: false,
|
|
1149
|
+
})
|
|
1150
|
+
return true
|
|
1151
|
+
})
|
|
1152
|
+
return unregister
|
|
1153
|
+
}, [formId, isPrimaryInstance, resourceKind, resourceId])
|
|
1154
|
+
|
|
1067
1155
|
const handleTakeOver = React.useCallback(async () => {
|
|
1068
1156
|
keepMineRetryVersionRef.current += 1
|
|
1069
1157
|
if (!state?.resourceKind || !state?.resourceId) return
|