@open-mercato/webhooks 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +68 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js +95 -0
- package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
- package/dist/modules/webhooks/backend/webhooks/page.js +13 -1
- package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js +1 -1
- package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
- package/package.json +8 -9
- package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +94 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
- package/src/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.ts +126 -0
- package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
- package/src/modules/webhooks/backend/webhooks/page.tsx +12 -1
- package/src/modules/webhooks/i18n/de.json +1 -0
- package/src/modules/webhooks/i18n/en.json +1 -0
- package/src/modules/webhooks/i18n/es.json +1 -0
- package/src/modules/webhooks/i18n/pl.json +1 -0
- package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.test.ts +31 -0
- package/src/modules/webhooks/subscribers/outbound-dispatch.ts +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
4
|
+
|
|
5
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174070'
|
|
6
|
+
const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
|
|
7
|
+
const STALE_VERSION = '2026-06-01T09:00:00.000Z'
|
|
8
|
+
|
|
9
|
+
const webhookRecord = {
|
|
10
|
+
id: WEBHOOK_ID,
|
|
11
|
+
name: 'Hook',
|
|
12
|
+
description: null,
|
|
13
|
+
url: 'https://example.com/hook',
|
|
14
|
+
subscribedEvents: ['a.b.c'],
|
|
15
|
+
httpMethod: 'POST',
|
|
16
|
+
isActive: true,
|
|
17
|
+
organizationId: 'org-1',
|
|
18
|
+
tenantId: 'tenant-1',
|
|
19
|
+
updatedAt: new Date(CURRENT_VERSION),
|
|
20
|
+
deletedAt: null as Date | null,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockEm = {
|
|
24
|
+
fork: jest.fn(() => mockEm),
|
|
25
|
+
flush: jest.fn(async () => undefined),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mockEmitWebhooksEvent = jest.fn(async () => undefined)
|
|
29
|
+
|
|
30
|
+
jest.mock('../../../../events', () => ({
|
|
31
|
+
emitWebhooksEvent: (...args: unknown[]) => mockEmitWebhooksEvent(...args),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
jest.mock('../../../helpers', () => ({
|
|
35
|
+
json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
|
|
36
|
+
new Response(JSON.stringify(payload), {
|
|
37
|
+
...init,
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
}),
|
|
40
|
+
resolveWebhookRequestScope: jest.fn(async () => ({ em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
|
|
41
|
+
findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
|
|
42
|
+
serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
|
|
43
|
+
id: item.id,
|
|
44
|
+
updatedAt: item.updatedAt.toISOString(),
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
49
|
+
resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
import { PUT, DELETE } from '../route'
|
|
53
|
+
|
|
54
|
+
function request(method: string, headerVersion: string | null, body?: unknown) {
|
|
55
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
56
|
+
if (headerVersion) headers[OPTIMISTIC_LOCK_HEADER_NAME] = headerVersion
|
|
57
|
+
return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
|
|
58
|
+
method,
|
|
59
|
+
headers,
|
|
60
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
|
|
65
|
+
|
|
66
|
+
describe('webhook endpoint PUT/DELETE optimistic locking', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
jest.clearAllMocks()
|
|
69
|
+
webhookRecord.deletedAt = null
|
|
70
|
+
delete process.env.OM_OPTIMISTIC_LOCK
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('PUT returns 409 with the structured conflict body when the expected version is stale', async () => {
|
|
74
|
+
const res = await PUT(request('PUT', STALE_VERSION, { name: 'X' }), context)
|
|
75
|
+
expect(res.status).toBe(409)
|
|
76
|
+
const body = await res.json()
|
|
77
|
+
expect(body.code).toBe('optimistic_lock_conflict')
|
|
78
|
+
expect(body.currentUpdatedAt).toBe(CURRENT_VERSION)
|
|
79
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('PUT succeeds when the expected version matches', async () => {
|
|
83
|
+
const res = await PUT(request('PUT', CURRENT_VERSION, { name: 'X' }), context)
|
|
84
|
+
expect(res.status).toBe(200)
|
|
85
|
+
expect(mockEm.flush).toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('PUT is a no-op (no 409) when the client sends no expected-version header', async () => {
|
|
89
|
+
const res = await PUT(request('PUT', null, { name: 'X' }), context)
|
|
90
|
+
expect(res.status).toBe(200)
|
|
91
|
+
expect(mockEm.flush).toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('DELETE returns 409 when the expected version is stale', async () => {
|
|
95
|
+
const res = await DELETE(request('DELETE', STALE_VERSION), context)
|
|
96
|
+
expect(res.status).toBe(409)
|
|
97
|
+
const body = await res.json()
|
|
98
|
+
expect(body.code).toBe('optimistic_lock_conflict')
|
|
99
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -4,6 +4,8 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
|
4
4
|
import { emitWebhooksEvent } from '../../../events'
|
|
5
5
|
import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'
|
|
6
6
|
import { webhookUpdateSchema } from '../../../data/validators'
|
|
7
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
7
9
|
|
|
8
10
|
export const metadata = {
|
|
9
11
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -69,6 +71,18 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
69
71
|
return json({ error: 'Webhook not found' }, { status: 404 })
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
try {
|
|
75
|
+
enforceCommandOptimisticLock({
|
|
76
|
+
resourceKind: 'webhooks.endpoint',
|
|
77
|
+
resourceId: webhook.id,
|
|
78
|
+
current: webhook.updatedAt ?? null,
|
|
79
|
+
request,
|
|
80
|
+
})
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
83
|
+
throw err
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))
|
|
73
87
|
if (!parsed.success) {
|
|
74
88
|
return json({ error: 'Invalid request payload' }, { status: 400 })
|
|
@@ -114,6 +128,18 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
|
|
|
114
128
|
return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })
|
|
115
129
|
}
|
|
116
130
|
|
|
131
|
+
try {
|
|
132
|
+
enforceCommandOptimisticLock({
|
|
133
|
+
resourceKind: 'webhooks.endpoint',
|
|
134
|
+
resourceId: webhook.id,
|
|
135
|
+
current: webhook.updatedAt ?? null,
|
|
136
|
+
request,
|
|
137
|
+
})
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
140
|
+
throw err
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
webhook.deletedAt = new Date()
|
|
118
144
|
await em.flush()
|
|
119
145
|
|
|
@@ -16,6 +16,8 @@ import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
|
16
16
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
17
17
|
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
|
|
18
18
|
import { deleteCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
19
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
20
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
19
21
|
import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
|
|
20
22
|
import {
|
|
21
23
|
buildWebhookFormContentHeader,
|
|
@@ -362,10 +364,14 @@ export default function WebhookDetailPage() {
|
|
|
362
364
|
const handleDelete = React.useCallback(async () => {
|
|
363
365
|
if (!webhook) return
|
|
364
366
|
try {
|
|
365
|
-
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
367
|
+
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
368
|
+
fallbackResult: null,
|
|
369
|
+
headers: buildOptimisticLockHeader(webhook.updatedAt),
|
|
370
|
+
})
|
|
366
371
|
flash(t('webhooks.list.deleteSuccess'), 'success')
|
|
367
372
|
router.push('/backend/webhooks')
|
|
368
|
-
} catch {
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (surfaceRecordConflict(error, t)) return
|
|
369
375
|
flash(t('webhooks.list.deleteError'), 'error')
|
|
370
376
|
}
|
|
371
377
|
}, [router, t, webhook])
|
|
@@ -497,6 +503,7 @@ export default function WebhookDetailPage() {
|
|
|
497
503
|
fields={fields}
|
|
498
504
|
groups={groups}
|
|
499
505
|
initialValues={createWebhookInitialValues(webhook)}
|
|
506
|
+
optimisticLockUpdatedAt={webhook.updatedAt}
|
|
500
507
|
submitLabel={t('common.save')}
|
|
501
508
|
cancelHref={`/backend/webhooks/${webhook.id}`}
|
|
502
509
|
contentHeader={contentHeader}
|
|
@@ -8,12 +8,15 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|
|
8
8
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
9
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
10
10
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
11
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
12
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
11
13
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
12
14
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
13
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
14
16
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
15
17
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
16
18
|
import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
|
|
19
|
+
import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
|
|
17
20
|
import { useWebhookFeatureAccess } from './useWebhookFeatureAccess'
|
|
18
21
|
|
|
19
22
|
type Row = {
|
|
@@ -107,10 +110,11 @@ export default function WebhooksListPage() {
|
|
|
107
110
|
try {
|
|
108
111
|
const call = await apiCall<{ error?: string }>(
|
|
109
112
|
`/api/webhooks/${encodeURIComponent(row.id)}`,
|
|
110
|
-
{ method: 'DELETE' },
|
|
113
|
+
{ method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },
|
|
111
114
|
{ fallback: null },
|
|
112
115
|
)
|
|
113
116
|
if (!call.ok) {
|
|
117
|
+
if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
|
|
114
118
|
const errorPayload = call.result as { error?: string } | undefined
|
|
115
119
|
const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')
|
|
116
120
|
flash(message, 'error')
|
|
@@ -276,6 +280,13 @@ export default function WebhooksListPage() {
|
|
|
276
280
|
|
|
277
281
|
return <RowActions items={items} />
|
|
278
282
|
}}
|
|
283
|
+
emptyState={(
|
|
284
|
+
<ListEmptyState
|
|
285
|
+
entityName={t('webhooks.list.title')}
|
|
286
|
+
createHref={access.canManage ? '/backend/webhooks/create' : undefined}
|
|
287
|
+
createLabel={access.canManage ? t('webhooks.nav.create') : undefined}
|
|
288
|
+
/>
|
|
289
|
+
)}
|
|
279
290
|
pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}
|
|
280
291
|
isLoading={isLoading}
|
|
281
292
|
/>
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"webhooks.detail.actions.deactivate": "Deactivate",
|
|
30
30
|
"webhooks.detail.actions.rotateSecret": "Rotate Secret",
|
|
31
31
|
"webhooks.detail.actions.test": "Send Test",
|
|
32
|
+
"webhooks.detail.backToList": "Zurück zu Webhooks",
|
|
32
33
|
"webhooks.detail.consecutiveFailures": "Consecutive Failures",
|
|
33
34
|
"webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
|
|
34
35
|
"webhooks.detail.loadError": "Failed to load webhook.",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"webhooks.detail.actions.deactivate": "Deactivate",
|
|
30
30
|
"webhooks.detail.actions.rotateSecret": "Rotate Secret",
|
|
31
31
|
"webhooks.detail.actions.test": "Send Test",
|
|
32
|
+
"webhooks.detail.backToList": "Back to webhooks",
|
|
32
33
|
"webhooks.detail.consecutiveFailures": "Consecutive Failures",
|
|
33
34
|
"webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
|
|
34
35
|
"webhooks.detail.loadError": "Failed to load webhook.",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"webhooks.detail.actions.deactivate": "Deactivate",
|
|
30
30
|
"webhooks.detail.actions.rotateSecret": "Rotate Secret",
|
|
31
31
|
"webhooks.detail.actions.test": "Send Test",
|
|
32
|
+
"webhooks.detail.backToList": "Volver a webhooks",
|
|
32
33
|
"webhooks.detail.consecutiveFailures": "Consecutive Failures",
|
|
33
34
|
"webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
|
|
34
35
|
"webhooks.detail.loadError": "Failed to load webhook.",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"webhooks.detail.actions.deactivate": "Deactivate",
|
|
30
30
|
"webhooks.detail.actions.rotateSecret": "Rotate Secret",
|
|
31
31
|
"webhooks.detail.actions.test": "Send Test",
|
|
32
|
+
"webhooks.detail.backToList": "Wróć do webhooków",
|
|
32
33
|
"webhooks.detail.consecutiveFailures": "Consecutive Failures",
|
|
33
34
|
"webhooks.detail.deliveryTip": "Start with a test delivery before enabling a broad event pattern. The delivery log below shows exactly what your consumer returned.",
|
|
34
35
|
"webhooks.detail.loadError": "Failed to load webhook.",
|
|
@@ -102,6 +102,37 @@ describe('webhooks outbound dispatch subscriber', () => {
|
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
it('normalizes a missing organizationId to null in the decryption scope', async () => {
|
|
106
|
+
const { rootEm } = createDispatchEntityManagers()
|
|
107
|
+
|
|
108
|
+
;(findWithDecryption as jest.Mock).mockResolvedValue([])
|
|
109
|
+
|
|
110
|
+
await handler(
|
|
111
|
+
{
|
|
112
|
+
id: 'product-1',
|
|
113
|
+
tenantId: 'tenant-1',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
eventName: 'catalog.product.deleted',
|
|
117
|
+
resolve: <T,>(name: string): T => {
|
|
118
|
+
if (name === 'em') return rootEm as T
|
|
119
|
+
throw new Error(`Unexpected dependency: ${name}`)
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expect(findWithDecryption).toHaveBeenCalledWith(
|
|
125
|
+
expect.anything(),
|
|
126
|
+
expect.anything(),
|
|
127
|
+
expect.objectContaining({ tenantId: 'tenant-1' }),
|
|
128
|
+
expect.anything(),
|
|
129
|
+
{ tenantId: 'tenant-1', organizationId: null },
|
|
130
|
+
)
|
|
131
|
+
const decryptionScope = (findWithDecryption as jest.Mock).mock.calls[0][4]
|
|
132
|
+
expect(decryptionScope.organizationId).toBeNull()
|
|
133
|
+
expect(decryptionScope.organizationId).not.toBe('')
|
|
134
|
+
})
|
|
135
|
+
|
|
105
136
|
it('checks integration state once per organization when multiple webhooks match', async () => {
|
|
106
137
|
const { rootEm, handlerEm } = createDispatchEntityManagers()
|
|
107
138
|
|