diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts index bdc655c6665..08347a980d5 100644 --- a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts +++ b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts @@ -1,8 +1,9 @@ -import type { IntegrationError, ActionDefinition } from '@segment/actions-core' +import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { external_id, lookup_field, data, enable_batching, batch_size, event_name } from '../properties' -import { addToList, addToListBatch, createList, getList } from '../functions' +import { addToList, addToListBatch } from '../functions' +import { retlOnMappingSaveHook } from '../retlOnMappingSaveHook' const action: ActionDefinition = { title: 'Add to List', @@ -17,80 +18,7 @@ const action: ActionDefinition = { event_name: { ...event_name } }, hooks: { - retlOnMappingSave: { - label: 'Connect to a static list in Marketo', - description: 'When saving this mapping, we will create a static list in Marketo using the fields you provided.', - inputFields: { - list_id: { - type: 'string', - label: 'Existing List ID', - description: - 'The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list.', - required: false - }, - list_name: { - type: 'string', - label: 'List Name', - description: 'The name of the Marketo Static List that you would like to create.', - required: false - } - }, - outputTypes: { - id: { - type: 'string', - label: 'ID', - description: 'The ID of the created Marketo Static List that users will be synced to.', - required: false - }, - name: { - type: 'string', - label: 'List Name', - description: 'The name of the created Marketo Static List that users will be synced to.', - required: false - } - }, - performHook: async (request, { settings, hookInputs, statsContext }) => { - if (hookInputs.list_id) { - try { - return getList(request, settings, hookInputs.list_id) - } catch (e) { - const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to get list' - const code = (e as IntegrationError).code || 'GET_LIST_FAILURE' - return { - error: { - message, - code - } - } - } - } - - try { - const input = { - audienceName: hookInputs.list_name, - settings: settings - } - const listId = await createList(request, input, statsContext) - - return { - successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`, - savedData: { - id: listId, - name: hookInputs.list_name - } - } - } catch (e) { - const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to create list' - const code = (e as IntegrationError).code || 'CREATE_LIST_FAILURE' - return { - error: { - message, - code - } - } - } - } - } + retlOnMappingSave: retlOnMappingSaveHook() }, perform: async (request, { settings, payload, statsContext, hookOutputs }) => { statsContext?.statsClient?.incr('addToAudience', 1, statsContext?.tags) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts b/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts index e9945f76869..3f6a758a200 100644 --- a/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts +++ b/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts @@ -41,7 +41,7 @@ export async function addToList( settings: Settings, payload: AddToListPayload, statsContext?: StatsContext, - hookOutputs?: { id: string; name: string } + hookOutputs?: { id?: string; name?: string } ) { // If the list ID is provided in the hook outputs, use it const list_id = hookOutputs?.id ?? payload.external_id @@ -86,7 +86,7 @@ export async function addToListBatch( settings: Settings, payloads: AddToListPayload[], statsContext?: StatsContext, - hookOutputs?: { id: string; name: string } + hookOutputs?: { id?: string; name?: string } ) { // If the list ID is provided in the hook outputs, use it const list_id = hookOutputs?.id ?? payloads[0].external_id @@ -154,10 +154,14 @@ export async function removeFromList( request: RequestClient, settings: Settings, payload: RemoveFromListPayload, - statsContext?: StatsContext + statsContext?: StatsContext, + hookOutputs?: { id?: string; name?: string } ) { - if (!payload.external_id) { - throw new IntegrationError('No "external_id" found in payload', ErrorCodes.PAYLOAD_VALIDATION_FAILED, 400) + // If the list ID is provided in the hook outputs, use it + const list_id = hookOutputs?.id ?? payload.external_id + + if (!list_id) { + throw new IntegrationError('No list ID found in payload', ErrorCodes.PAYLOAD_VALIDATION_FAILED, 400) } const api_endpoint = formatEndpoint(settings.api_endpoint) @@ -186,7 +190,7 @@ export async function removeFromList( const leadIds = extractLeadIds(getLeadsResponse.data.result) const deleteLeadsUrl = - api_endpoint + REMOVE_USERS_ENDPOINT.replace('listId', payload.external_id).replace('idsToDelete', leadIds) + api_endpoint + REMOVE_USERS_ENDPOINT.replace('listId', list_id).replace('idsToDelete', leadIds) // DELETE lead ids from list in Marketo const deleteLeadsResponse = await request(deleteLeadsUrl, { @@ -208,13 +212,17 @@ export async function removeFromListBatch( request: RequestClient, settings: Settings, payloads: RemoveFromListPayload[], - statsContext?: StatsContext + statsContext?: StatsContext, + hookOutputs?: { id?: string; name?: string } ) { - if (!payloads[0].external_id) { + // If the list ID is provided in the hook outputs, use it + const list_id = hookOutputs?.id ?? payloads[0].external_id + + if (!list_id) { return buildMultiStatusErrorResponse(payloads.length, { status: 400, errortype: ErrorCodes.PAYLOAD_VALIDATION_FAILED, - errormessage: 'No "external_id" found in payload' + errormessage: 'No list ID found in payload' }) } @@ -248,7 +256,7 @@ export async function removeFromListBatch( const leadIds = extractLeadIds(getLeadsResponse.data.result) const deleteLeadsUrl = - api_endpoint + REMOVE_USERS_ENDPOINT.replace('listId', payloads[0].external_id).replace('idsToDelete', leadIds) + api_endpoint + REMOVE_USERS_ENDPOINT.replace('listId', list_id).replace('idsToDelete', leadIds) // DELETE lead ids from list in Marketo const deleteLeadsResponse = await request(deleteLeadsUrl, { diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/index.ts index 08b21efc127..9ffb0f21955 100644 --- a/packages/destination-actions/src/destinations/marketo-static-lists/index.ts +++ b/packages/destination-actions/src/destinations/marketo-static-lists/index.ts @@ -4,6 +4,7 @@ import type { Settings } from './generated-types' import addToList from './addToList' import removeFromList from './removeFromList' +import syncList from './syncList' import { MarketoListResponse, GET_LIST_ENDPOINT } from './constants' import { createList, formatEndpoint, getAccessToken } from './functions' @@ -96,7 +97,8 @@ const destination: AudienceDestinationDefinition = { }, actions: { addToList, - removeFromList + removeFromList, + syncList }, presets: [ { @@ -133,6 +135,13 @@ const destination: AudienceDestinationDefinition = { mapping: { ...defaultValues(addToList.fields) }, type: 'specificEvent', eventSlug: 'journeys_step_entered_track' + }, + { + name: 'Journey Step All Events', + partnerAction: 'syncList', + mapping: defaultValues(syncList.fields), + type: 'specificEvent', + eventSlug: 'journey_step_all_events_track' } ] } diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/metadata.json b/packages/destination-actions/src/destinations/marketo-static-lists/metadata.json index 990e5a9b305..a1b64937820 100644 --- a/packages/destination-actions/src/destinations/marketo-static-lists/metadata.json +++ b/packages/destination-actions/src/destinations/marketo-static-lists/metadata.json @@ -615,6 +615,414 @@ "additionalProperties": false } } + }, + "syncList": { + "title": "Sync List", + "description": "Add or remove users from a list in Marketo", + "platform": "cloud", + "defaultSubscription": "type = track or type = identify", + "hidden": false, + "hasPerformBatch": true, + "syncMode": { + "default": "mirror", + "label": "Sync Mode", + "description": "Specify how Segment should sync data to Marketo when connected to a database Source.", + "choices": [ + { + "value": "add", + "label": "Add - when connected to a database Source, adding a row will trigger this mapping" + }, + { + "value": "update", + "label": "Update - when connected to a database Source, updating a row will trigger this mapping" + }, + { + "value": "upsert", + "label": "Upsert - when connected to a database Source, adding or updating a row will trigger this mapping" + }, + { + "value": "delete", + "label": "Delete - when connected to a database Source, deleting a row will trigger this mapping" + }, + { + "value": "mirror", + "label": "Mirror - when connected to a database Source, adding, updating, or deleting a row will trigger this mapping" + } + ] + }, + "hooks": { + "retlOnMappingSave": { + "label": "Connect to a static list in Marketo", + "description": "When saving this mapping, we will create a static list in Marketo using the fields you provided.", + "inputFields": { + "list_id": { + "label": "Existing List ID", + "description": "The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "list_name": { + "label": "List Name", + "description": "The name of the Marketo Static List that you would like to create.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + }, + "outputFields": { + "id": { + "label": "ID", + "description": "The ID of the created Marketo Static List that users will be synced to.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "name": { + "label": "List Name", + "description": "The name of the created Marketo Static List that users will be synced to.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } + } + }, + "dynamicFields": null, + "fields": { + "external_id": { + "label": "External ID", + "description": "The ID of the Static List that users will be synced to.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.personas.external_audience_id" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": true, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "lookup_field": { + "label": "Lookup Field", + "description": "The lead field to use for deduplication and filtering. This field must be apart of the Lead Info Fields below.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "email", + "choices": [ + { + "label": "Email", + "value": "email" + }, + { + "label": "Id", + "value": "id" + }, + { + "label": "Cookies", + "value": "cookies" + }, + { + "label": "Twitter ID", + "value": "twitterId" + }, + { + "label": "Facebook ID", + "value": "facebookId" + }, + { + "label": "LinkedIn ID", + "value": "linkedinId" + }, + { + "label": "Salesforce Account ID", + "value": "sfdcAccountId" + }, + { + "label": "Salesforce Contact ID", + "value": "sfdcContactId" + }, + { + "label": "Salesforce Lead ID", + "value": "sfdcLeadId" + }, + { + "label": "Salesforce Opportunity ID", + "value": "sfdcOpptyId" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "data": { + "label": "Lead Info Fields", + "description": "The fields that contain data about the lead, such as Email, Last Name, etc. On the left-hand side, input the field name exactly how it appears in Marketo. On the right-hand side, map the Segment field that contains the corresponding value.", + "type": "object", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "email": { + "@if": { + "exists": { + "@path": "$.context.traits.email" + }, + "then": { + "@path": "$.context.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + } + }, + "choices": null, + "placeholder": null, + "properties": { + "email": { + "label": "Email", + "description": "The user's email address to send to Marketo.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + }, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": true + }, + "field_value": { + "label": "Field Value", + "description": "The value cooresponding to the lookup field.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@if": { + "exists": { + "@path": "$.context.traits.email" + }, + "then": { + "@path": "$.context.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "enable_batching": { + "label": "Enable Batching", + "description": "Enable batching of requests.", + "type": "boolean", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": true, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": true, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "batch_size": { + "label": "Batch Size", + "description": "Maximum number of events to include in each batch. Actual batch sizes may be lower.", + "type": "number", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": 300, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": false, + "minimum": 1, + "maximum": 300, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_name": { + "label": "Event Name", + "description": "The name of the current Segment event.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.event" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": true, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } } }, "presets": [ @@ -773,6 +1181,51 @@ } }, "eventSlug": "journeys_step_entered_track" + }, + { + "name": "Journey Step All Events", + "type": "specificEvent", + "partnerAction": "syncList", + "mapping": { + "external_id": { + "@path": "$.context.personas.external_audience_id" + }, + "lookup_field": "email", + "data": { + "email": { + "@if": { + "exists": { + "@path": "$.context.traits.email" + }, + "then": { + "@path": "$.context.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + } + }, + "field_value": { + "@if": { + "exists": { + "@path": "$.context.traits.email" + }, + "then": { + "@path": "$.context.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + }, + "enable_batching": true, + "batch_size": 300, + "event_name": { + "@path": "$.event" + } + }, + "eventSlug": "journey_step_all_events_track" } ] } diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/retlOnMappingSaveHook.ts b/packages/destination-actions/src/destinations/marketo-static-lists/retlOnMappingSaveHook.ts new file mode 100644 index 00000000000..8e35b8f5cae --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/retlOnMappingSaveHook.ts @@ -0,0 +1,81 @@ +import type { IntegrationError } from '@segment/actions-core' +import type { ActionHookDefinition } from '@segment/actions-core/destination-kit' +import type { Settings } from './generated-types' +import { createList, getList } from './functions' + +export function retlOnMappingSaveHook(): ActionHookDefinition { + return { + label: 'Connect to a static list in Marketo', + description: 'When saving this mapping, we will create a static list in Marketo using the fields you provided.', + inputFields: { + list_id: { + type: 'string', + label: 'Existing List ID', + description: + 'The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list.', + required: false + }, + list_name: { + type: 'string', + label: 'List Name', + description: 'The name of the Marketo Static List that you would like to create.', + required: false + } + }, + outputTypes: { + id: { + type: 'string', + label: 'ID', + description: 'The ID of the created Marketo Static List that users will be synced to.', + required: false + }, + name: { + type: 'string', + label: 'List Name', + description: 'The name of the created Marketo Static List that users will be synced to.', + required: false + } + }, + performHook: async (request, { settings, hookInputs, statsContext }) => { + if (hookInputs.list_id) { + try { + return getList(request, settings, hookInputs.list_id) + } catch (e) { + const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to get list' + const code = (e as IntegrationError).code || 'GET_LIST_FAILURE' + return { + error: { + message, + code + } + } + } + } + + try { + const input = { + audienceName: hookInputs.list_name, + settings: settings + } + const listId = await createList(request, input, statsContext) + + return { + successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`, + savedData: { + id: listId, + name: hookInputs.list_name + } + } + } catch (e) { + const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to create list' + const code = (e as IntegrationError).code || 'CREATE_LIST_FAILURE' + return { + error: { + message, + code + } + } + } + } + } +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/audience-membership.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/audience-membership.test.ts new file mode 100644 index 00000000000..181d2ae0145 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/audience-membership.test.ts @@ -0,0 +1,96 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, FLAGS } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const LIST_ID = '12345' +const API_ENDPOINT = 'https://123-ABC-456.mktorest.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: API_ENDPOINT, + folder_name: 'Test Folder' +} + +const mapping = { + external_id: { '@path': '$.context.personas.external_audience_id' }, + lookup_field: 'email', + data: { + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + field_value: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + enable_batching: true, + batch_size: 300, + event_name: { '@path': '$.event' } +} + +// This resolution itself lives in actions-core (packages/core/src/audience-membership.ts, +// legacyJourneysAudienceMembership - journey_step + no boolean at properties[computation_key] => +// always an add), gated behind FLAGS.ACTIONS_LEGACY_JOURNEYS_AUDIENCE_MEMBERSHIP. These tests +// re-verify that resolution's effect at the destination level: syncList must actually route a +// legacy Journeys (V1) event to addToList, not just receive `true` from core in isolation. +const LEGACY_JOURNEYS_FLAG = { [FLAGS.ACTIONS_LEGACY_JOURNEYS_AUDIENCE_MEMBERSHIP]: true } + +// Legacy Journeys V1 event: journey_step computation_class, but no boolean at +// properties[computation_key] - V1 payloads never carry one. +function legacyJourneysEvent(email: string) { + return createTestEvent({ + event: 'Journeys Step Entered', + type: 'track', + properties: { email }, + context: { + traits: { email }, + personas: { + computation_class: 'journey_step', + computation_key: 'my_journey', + external_audience_id: LIST_ID + } + } + }) +} + +describe('MarketoStaticLists.syncList - legacy JourneysV1 audience membership', () => { + beforeEach(() => nock.cleanAll()) + + it('always adds the user when the legacy journeys flag is enabled and no membership boolean is present', async () => { + const scope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + const r = await testDestination.testAction('syncList', { + event: legacyJourneysEvent('legacy-user@example.com'), + settings, + mapping, + features: LEGACY_JOURNEYS_FLAG + }) + + expect(r[0].status).toEqual(200) + expect(scope.isDone()).toBe(true) + }) + + it('throws instead of defaulting to add when the same event arrives without the legacy journeys flag', async () => { + // Proves the "always add" behavior above is genuinely gated by the flag, not just an + // incidental effect of the journey_step event shape. + await expect( + testDestination.testAction('syncList', { + event: legacyJourneysEvent('legacy-user@example.com'), + settings, + mapping + }) + ).rejects.toThrow('Audience Membership must be a boolean') + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/batch.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/batch.test.ts new file mode 100644 index 00000000000..9d4c75d4021 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/batch.test.ts @@ -0,0 +1,171 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT, GET_LEADS_ENDPOINT, REMOVE_USERS_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const LIST_ID = '12345' +const API_ENDPOINT = 'https://123-ABC-456.mktorest.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: API_ENDPOINT, + folder_name: 'Test Folder' +} + +const mapping = { + external_id: { '@path': '$.context.personas.external_audience_id' }, + lookup_field: 'email', + data: { + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + field_value: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + enable_batching: true, + batch_size: 300, + event_name: { '@path': '$.event' } +} + +// A valid Engage-style add/remove event: computation_class/computation_key + a membership +// boolean at properties[computation_key], plus an email so field_value/data.email resolve. +function membershipEvent(email: string, membership: boolean) { + return createTestEvent({ + event: 'Test Event', + type: 'track', + properties: { + my_audience: membership, + email + }, + context: { + traits: { email }, + personas: { + computation_class: 'audience', + computation_key: 'my_audience', + external_audience_id: LIST_ID + } + } + }) +} + +// No email anywhere => field_value's default (`@if exists traits.email then ... else properties.email`) +// resolves to undefined, and field_value is `required: true` => AJV schema validation fails on this +// event BEFORE performBatch is ever called. The framework filters it out and records the error at its +// original index itself. +function schemaInvalidEvent() { + return createTestEvent({ + event: 'Test Event', + type: 'track', + properties: {} + }) +} + +// Has an email (so schema validation passes) but no context.personas at all, and the batch's mapping +// carries no `__segment_internal_sync_mode` => resolveAudienceMembership (core) returns undefined for +// this event. It reaches syncListBatch, which sets its own per-index MultiStatus validation error. +function businessInvalidEvent(email: string) { + return createTestEvent({ + event: 'Test Event', + type: 'track', + properties: { email }, + context: { traits: { email } } + }) +} + +describe('MarketoStaticLists.syncList - mixed batch MultiStatus correctness', () => { + beforeEach(() => nock.cleanAll()) + + it('preserves original indices across add/remove successes and both kinds of validation failure, interspersed', async () => { + // 10 events, interleaved so no two adjacent events are the same kind - this is deliberately + // NOT grouped by type, to prove index-preservation isn't an artifact of contiguous blocks. + // 0: add 1: schema-invalid 2: remove 3: business-invalid 4: add + // 5: remove 6: schema-invalid 7: business-invalid 8: remove 9: add + const events = [ + membershipEvent('u0@example.com', true), // 0: add + schemaInvalidEvent(), // 1: schema-invalid + membershipEvent('u2@example.com', false), // 2: remove + businessInvalidEvent('u3@example.com'), // 3: business-invalid + membershipEvent('u4@example.com', true), // 4: add + membershipEvent('u5@example.com', false), // 5: remove + schemaInvalidEvent(), // 6: schema-invalid + businessInvalidEvent('u7@example.com'), // 7: business-invalid + membershipEvent('u8@example.com', false), // 8: remove + membershipEvent('u9@example.com', true) // 9: add + ] + + // Add bucket (indices 0, 4, 9): single bulk-CSV import POST for all three at once. + const addScope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + // Remove bucket (indices 2, 5, 8): GET leads by email, then DELETE by the returned lead ids. + // extractFilterData joins field_value in payload order, which follows the original relative + // order of the remove events in the batch: index 2, then 5, then 8. + const filterValues = encodeURIComponent('u2@example.com,u5@example.com,u8@example.com') + const getLeadsScope = nock(API_ENDPOINT) + .get(GET_LEADS_ENDPOINT.replace('field', 'email').replace('emailsToFilter', filterValues)) + .reply(200, { + success: true, + // Ids deliberately mirror each user's original batch index, so the expected `sent` value + // below (`id=2`, `id=5`, `id=8`) makes the index correspondence obvious at a glance. + result: [{ id: 2 }, { id: 5 }, { id: 8 }] + }) + + const deleteLeadsScope = nock(API_ENDPOINT) + .delete(REMOVE_USERS_ENDPOINT.replace('listId', LIST_ID).replace('idsToDelete', '2,5,8')) + .reply(200, { success: true }) + + const responses = await testDestination.executeBatch('syncList', { + events, + settings, + mapping + }) + + expect(responses.length).toBe(10) + + // --- Adds: 0, 4, 9 --- + expect(responses[0]).toMatchObject({ status: 200, sent: 'u0@example.com', body: { success: true } }) + expect(responses[4]).toMatchObject({ status: 200, sent: 'u4@example.com', body: { success: true } }) + expect(responses[9]).toMatchObject({ status: 200, sent: 'u9@example.com', body: { success: true } }) + + // --- Removes: 2, 5, 8 --- + expect(responses[2]).toMatchObject({ status: 200, sent: 'id=2', body: { success: true } }) + expect(responses[5]).toMatchObject({ status: 200, sent: 'id=5', body: { success: true } }) + expect(responses[8]).toMatchObject({ status: 200, sent: 'id=8', body: { success: true } }) + + // --- Schema-invalid (rejected before performBatch, by the framework itself): 1, 6 --- + expect(responses[1]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }) + expect(responses[6]).toMatchObject({ status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errorreporter: 'INTEGRATIONS' }) + expect(responses[1].errormessage).toBe("The root value is missing the required field 'field_value'.") + expect(responses[6].errormessage).toBe("The root value is missing the required field 'field_value'.") + + // --- Business-invalid (rejected inside syncListBatch itself): 3, 7 --- + expect(responses[3]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'INTEGRATIONS', + errormessage: 'Audience Membership must be a boolean' + }) + expect(responses[7]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'INTEGRATIONS', + errormessage: 'Audience Membership must be a boolean' + }) + + expect(addScope.isDone()).toBe(true) + expect(getLeadsScope.isDone()).toBe(true) + expect(deleteLeadsScope.isDone()).toBe(true) + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/hook.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/hook.test.ts new file mode 100644 index 00000000000..4f69da76870 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/hook.test.ts @@ -0,0 +1,109 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT, GET_LEADS_ENDPOINT, REMOVE_USERS_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const API_ENDPOINT = 'https://123-ABC-456.mktorest.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: API_ENDPOINT, + folder_name: 'Test Folder' +} + +const HOOK_LIST_ID = '999' +const HOOK_LIST_NAME = 'Hook-Created List' + +// No context.personas.external_audience_id at all - a realistic RETL event, where the only way +// to get a list id is via the retlOnMappingSave hook's saved output (functions.ts: +// `hookOutputs?.id ?? payload.external_id`, now also applied to removeFromList/removeFromListBatch). +function retlEventWithoutExternalId(email: string, membership: boolean) { + return createTestEvent({ + event: 'Test Event', + type: 'track', + properties: { my_audience: membership, email }, + context: { + traits: { email }, + personas: { + computation_class: 'audience', + computation_key: 'my_audience' + } + } + }) +} + +const mapping = { + lookup_field: 'email', + data: { + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + field_value: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + enable_batching: true, + batch_size: 300, + event_name: { '@path': '$.event' }, + retlOnMappingSave: { + outputs: { + id: HOOK_LIST_ID, + name: HOOK_LIST_NAME + } + } +} + +describe('MarketoStaticLists.syncList - retlOnMappingSave hook output as list id', () => { + beforeEach(() => nock.cleanAll()) + + it('uses the saved hook list id for the add branch', async () => { + const scope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', HOOK_LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + const r = await testDestination.testAction('syncList', { + event: retlEventWithoutExternalId('add-user@example.com', true), + settings, + mapping + }) + + expect(r[0].status).toEqual(200) + expect(scope.isDone()).toBe(true) + }) + + it('uses the saved hook list id for the remove branch too', async () => { + const getLeadsScope = nock(API_ENDPOINT) + .get( + GET_LEADS_ENDPOINT.replace('field', 'email').replace( + 'emailsToFilter', + encodeURIComponent('remove-user@example.com') + ) + ) + .reply(200, { success: true, result: [{ id: 55 }] }) + + const deleteLeadsScope = nock(API_ENDPOINT) + .delete(REMOVE_USERS_ENDPOINT.replace('listId', HOOK_LIST_ID).replace('idsToDelete', '55')) + .reply(200, { success: true }) + + const r = await testDestination.testAction('syncList', { + event: retlEventWithoutExternalId('remove-user@example.com', false), + settings, + mapping + }) + + expect(r[0].status).toEqual(200) + expect(r[1].status).toEqual(200) + expect(getLeadsScope.isDone()).toBe(true) + expect(deleteLeadsScope.isDone()).toBe(true) + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/index.test.ts new file mode 100644 index 00000000000..5a11d8ce7aa --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/index.test.ts @@ -0,0 +1,122 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT, GET_LEADS_ENDPOINT, REMOVE_USERS_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const LIST_ID = '12345' +const API_ENDPOINT = 'https://123-ABC-456.mktorest.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: API_ENDPOINT, + folder_name: 'Test Folder' +} + +const mapping = { + external_id: { '@path': '$.context.personas.external_audience_id' }, + lookup_field: 'email', + data: { + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + field_value: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + enable_batching: true, + batch_size: 300, + event_name: { '@path': '$.event' } +} + +function membershipEvent(email: string, membership: boolean) { + return createTestEvent({ + event: 'Test Event', + type: 'track', + properties: { + my_audience: membership, + email + }, + context: { + traits: { email }, + personas: { + computation_class: 'audience', + computation_key: 'my_audience', + external_audience_id: LIST_ID + } + } + }) +} + +describe('MarketoStaticLists.syncList', () => { + beforeEach(() => nock.cleanAll()) + + it('calls addToList when audienceMembership is true', async () => { + const scope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + const r = await testDestination.testAction('syncList', { + event: membershipEvent('add-user@example.com', true), + settings, + mapping + }) + + expect(r[0].status).toEqual(200) + expect(scope.isDone()).toBe(true) + }) + + it('calls removeFromList when audienceMembership is false', async () => { + const getLeadsScope = nock(API_ENDPOINT) + .get( + GET_LEADS_ENDPOINT.replace('field', 'email').replace( + 'emailsToFilter', + encodeURIComponent('remove-user@example.com') + ) + ) + .reply(200, { success: true, result: [{ id: 42 }] }) + + const deleteLeadsScope = nock(API_ENDPOINT) + .delete(REMOVE_USERS_ENDPOINT.replace('listId', LIST_ID).replace('idsToDelete', '42')) + .reply(200, { success: true }) + + const r = await testDestination.testAction('syncList', { + event: membershipEvent('remove-user@example.com', false), + settings, + mapping + }) + + expect(r[0].status).toEqual(200) + expect(r[1].status).toEqual(200) + expect(getLeadsScope.isDone()).toBe(true) + expect(deleteLeadsScope.isDone()).toBe(true) + }) + + it('throws PayloadValidationError when audienceMembership is not a boolean', async () => { + // No context.personas at all, and no __segment_internal_sync_mode in the mapping => + // resolveAudienceMembership (core) returns undefined for this event. + const event = createTestEvent({ + event: 'Test Event', + type: 'track', + properties: { email: 'no-membership@example.com' }, + context: { traits: { email: 'no-membership@example.com' } } + }) + + await expect( + testDestination.testAction('syncList', { + event, + settings, + mapping + }) + ).rejects.toThrow('Audience Membership must be a boolean') + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/sync-mode.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/sync-mode.test.ts new file mode 100644 index 00000000000..54f291155c6 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/__tests__/sync-mode.test.ts @@ -0,0 +1,114 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT, GET_LEADS_ENDPOINT, REMOVE_USERS_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const LIST_ID = '12345' +const API_ENDPOINT = 'https://123-ABC-456.mktorest.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: API_ENDPOINT, + folder_name: 'Test Folder' +} + +const baseMapping = { + external_id: LIST_ID, + lookup_field: 'email', + data: { email: { '@path': '$.properties.email' } }, + field_value: { '@path': '$.properties.email' }, + enable_batching: true, + batch_size: 300, + event_name: { '@path': '$.event' } +} + +// A RETL/database-table row event: plain track event named 'new'/'updated'/'deleted', no +// context.personas at all. Add vs. remove is derived purely from syncMode + event name via +// core's retlAudienceMembership (packages/core/src/audience-membership.ts) - which only runs +// because syncList declares a top-level `syncMode` field (action.ts only reads +// `__segment_internal_sync_mode` when `this.definition.syncMode` is set). +function retlRowEvent(event: 'new' | 'updated' | 'deleted', email: string) { + return createTestEvent({ + event, + type: 'track', + properties: { email } + }) +} + +describe('MarketoStaticLists.syncList - syncMode-driven RETL audience membership', () => { + beforeEach(() => nock.cleanAll()) + + describe('syncMode: upsert', () => { + it('treats both "new" and "updated" rows as adds', async () => { + const events = [retlRowEvent('new', 'new-row@example.com'), retlRowEvent('updated', 'updated-row@example.com')] + + const addScope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + const responses = await testDestination.executeBatch('syncList', { + events, + settings, + mapping: { ...baseMapping, __segment_internal_sync_mode: 'upsert' } + }) + + expect(responses).toMatchObject([ + { status: 200, sent: 'new-row@example.com' }, + { status: 200, sent: 'updated-row@example.com' } + ]) + expect(addScope.isDone()).toBe(true) + }) + }) + + describe('syncMode: mirror', () => { + it('treats a "new" row as an add and a "deleted" row as a remove', async () => { + const events = [retlRowEvent('new', 'new-row@example.com'), retlRowEvent('deleted', 'deleted-row@example.com')] + + const addScope = nock(API_ENDPOINT) + .post(BULK_IMPORT_ENDPOINT.replace('externalId', LIST_ID).replace('fieldToLookup', 'email')) + .reply(200, { success: true }) + + const getLeadsScope = nock(API_ENDPOINT) + .get( + GET_LEADS_ENDPOINT.replace('field', 'email').replace( + 'emailsToFilter', + encodeURIComponent('deleted-row@example.com') + ) + ) + .reply(200, { success: true, result: [{ id: 7 }] }) + + const deleteLeadsScope = nock(API_ENDPOINT) + .delete(REMOVE_USERS_ENDPOINT.replace('listId', LIST_ID).replace('idsToDelete', '7')) + .reply(200, { success: true }) + + const responses = await testDestination.executeBatch('syncList', { + events, + settings, + mapping: { ...baseMapping, __segment_internal_sync_mode: 'mirror' } + }) + + expect(responses).toMatchObject([ + { status: 200, sent: 'new-row@example.com' }, + { status: 200, sent: 'id=7' } + ]) + expect(addScope.isDone()).toBe(true) + expect(getLeadsScope.isDone()).toBe(true) + expect(deleteLeadsScope.isDone()).toBe(true) + }) + }) + + it('is unresolvable (and rejected) when __segment_internal_sync_mode is missing from the mapping', async () => { + // Same event shape as the "upsert"/"mirror" cases above, but the mapping carries no sync + // mode at all - proving the syncMode field is load-bearing, not cosmetic: without it, core's + // retlAudienceMembership never runs and this RETL-style event has no other resolution path. + await expect( + testDestination.testAction('syncList', { + event: retlRowEvent('new', 'no-sync-mode@example.com'), + settings, + mapping: baseMapping + }) + ).rejects.toThrow('Audience Membership must be a boolean') + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/functions.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/functions.ts new file mode 100644 index 00000000000..734e85e4fff --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/functions.ts @@ -0,0 +1,85 @@ +import { + AudienceMembership, + ErrorCodes, + MultiStatusResponse, + PayloadValidationError, + RequestClient, + StatsContext +} from '@segment/actions-core' +import { Settings } from '../generated-types' +import { Payload } from './generated-types' +import { addToList, addToListBatch, removeFromList, removeFromListBatch } from '../functions' + +export async function syncList( + request: RequestClient, + settings: Settings, + payload: Payload, + audienceMembership: AudienceMembership, + statsContext?: StatsContext, + hookOutputs?: { id?: string; name?: string } +) { + if (audienceMembership === true) { + return addToList(request, settings, payload, statsContext, hookOutputs) + } else if (audienceMembership === false) { + return removeFromList(request, settings, payload, statsContext, hookOutputs) + } + + throw new PayloadValidationError('Audience Membership must be a boolean') +} + +export async function syncListBatch( + request: RequestClient, + settings: Settings, + payloads: Payload[], + audienceMembership: AudienceMembership[], + statsContext?: StatsContext, + hookOutputs?: { id?: string; name?: string } +): Promise { + const multiStatusResponse = new MultiStatusResponse() + const addIndices: number[] = [] + const addPayloads: Payload[] = [] + const removeIndices: number[] = [] + const removePayloads: Payload[] = [] + + payloads.forEach((payload, index) => { + const membership = audienceMembership[index] + + if (membership !== true && membership !== false) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: ErrorCodes.PAYLOAD_VALIDATION_FAILED, + errormessage: 'Audience Membership must be a boolean' + }) + return + } + + if (membership) { + addIndices.push(index) + addPayloads.push(payload) + } else { + removeIndices.push(index) + removePayloads.push(payload) + } + }) + + const [addResult, removeResult] = await Promise.all([ + addPayloads.length > 0 ? addToListBatch(request, settings, addPayloads, statsContext, hookOutputs) : undefined, + removePayloads.length > 0 + ? removeFromListBatch(request, settings, removePayloads, statsContext, hookOutputs) + : undefined + ]) + + if (addResult) { + addIndices.forEach((originalIndex, i) => { + multiStatusResponse.pushResponseObjectAtIndex(originalIndex, addResult.getResponseAtIndex(i)) + }) + } + + if (removeResult) { + removeIndices.forEach((originalIndex, i) => { + multiStatusResponse.pushResponseObjectAtIndex(originalIndex, removeResult.getResponseAtIndex(i)) + }) + } + + return multiStatusResponse +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/generated-types.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/generated-types.ts new file mode 100644 index 00000000000..923bceee44f --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/generated-types.ts @@ -0,0 +1,62 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the Static List that users will be synced to. + */ + external_id?: string + /** + * The lead field to use for deduplication and filtering. This field must be apart of the Lead Info Fields below. + */ + lookup_field: string + /** + * The fields that contain data about the lead, such as Email, Last Name, etc. On the left-hand side, input the field name exactly how it appears in Marketo. On the right-hand side, map the Segment field that contains the corresponding value. + */ + data: { + /** + * The user's email address to send to Marketo. + */ + email?: string + [k: string]: unknown + } + /** + * The value cooresponding to the lookup field. + */ + field_value: string + /** + * Enable batching of requests. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size: number + /** + * The name of the current Segment event. + */ + event_name: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveInputs { + /** + * The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list. + */ + list_id?: string + /** + * The name of the Marketo Static List that you would like to create. + */ + list_name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveOutputs { + /** + * The ID of the created Marketo Static List that users will be synced to. + */ + id?: string + /** + * The name of the created Marketo Static List that users will be synced to. + */ + name?: string +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/syncList/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/index.ts new file mode 100644 index 00000000000..a3400a93cd2 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/syncList/index.ts @@ -0,0 +1,66 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { external_id, lookup_field, data, field_value, enable_batching, batch_size, event_name } from '../properties' +import { retlOnMappingSaveHook } from '../retlOnMappingSaveHook' +import { syncList, syncListBatch } from './functions' + +const action: ActionDefinition = { + title: 'Sync List', + description: 'Add or remove users from a list in Marketo', + defaultSubscription: 'type = track or type = identify', + syncMode: { + label: 'Sync Mode', + description: 'Specify how Segment should sync data to Marketo when connected to a database Source.', + default: 'mirror', + choices: [ + { label: 'Add - when connected to a database Source, adding a row will trigger this mapping', value: 'add' }, + { label: 'Update - when connected to a database Source, updating a row will trigger this mapping', value: 'update' }, + { + label: 'Upsert - when connected to a database Source, adding or updating a row will trigger this mapping', + value: 'upsert' + }, + { label: 'Delete - when connected to a database Source, deleting a row will trigger this mapping', value: 'delete' }, + { + label: 'Mirror - when connected to a database Source, adding, updating, or deleting a row will trigger this mapping', + value: 'mirror' + } + ] + }, + fields: { + external_id: { ...external_id }, + lookup_field: { ...lookup_field }, + data: { ...data }, + field_value: { ...field_value }, + enable_batching: { ...enable_batching }, + batch_size: { ...batch_size, default: 300, maximum: 300 }, + event_name: { ...event_name } + }, + hooks: { + retlOnMappingSave: retlOnMappingSaveHook() + }, + perform: async (request, { settings, payload, statsContext, hookOutputs, audienceMembership }) => { + statsContext?.statsClient?.incr('syncList', 1, statsContext?.tags) + return syncList( + request, + settings, + payload, + audienceMembership, + statsContext, + hookOutputs?.retlOnMappingSave?.outputs + ) + }, + performBatch: async (request, { settings, payload, statsContext, hookOutputs, audienceMembership }) => { + statsContext?.statsClient?.incr('syncList.batch', 1, statsContext?.tags) + return syncListBatch( + request, + settings, + payload, + audienceMembership ?? [], + statsContext, + hookOutputs?.retlOnMappingSave?.outputs + ) + } +} + +export default action