STRATCONN-6637 [Hubspot] Fix duplicate ids and missing associations in Custom Object V2 batches - #3969
STRATCONN-6637 [Hubspot] Fix duplicate ids and missing associations in Custom Object V2 batches#3969monutwilio wants to merge 2 commits into
Conversation
… requests Custom Object V2 sent one associated-record input per association payload. A mapping that associates the same record under two association labels therefore repeated that record's id within a single HubSpot batch, which the id property's uniqueness constraint rejects with a 400. The contact upsert had already committed by then, so records synced while their companies and associations were never created. Dedupe the request inputs by id_field_value in readAssociatedRecords and upsertAssociatedRecords, leaving the grouped payload list untouched so every label still gets its association. returnAssociatedRecordsWithIds matches responses by property value rather than by index, so a request carrying fewer inputs than its group still stamps record_id on all of them. Also add from_record_id to the deDuplicateAssociations key. Without it, two records associating to the same target under the same label collapsed into one and the second silently lost its association - a latent data-loss bug that the input dedupe above would otherwise have masked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Fixes HubSpot batch failures and association data-loss by adjusting de-duplication behavior for associated-record upserts/reads and association creates.
Changes:
- De-duplicate associated-record batch
inputsbyid_field_value(while keeping payloads intact so all labels still generate associations). - Fix association de-duplication to include the parent (
from_record_id) in the key to prevent silently dropping associations across different “from” records. - Add unit tests covering multi-label and shared-associated-record scenarios (read + upsert modes).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts | Updates association de-duplication key to include from_record_id. |
| packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts | Adds uniqueRecordsById and uses it to de-dupe batch read/upsert inputs for associated records. |
| packages/destination-actions/src/destinations/hubspot/upsertObject/tests/multi-label-associations.test.ts | Adds tests asserting request bodies for multi-label and shared associated-record cases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts:99
uniqueRecordsByIdcurrently overwrites the stored payload for duplicate keys, butMapiteration order remains based on the first insertion. That creates a subtle mismatch: the returned element order corresponds to first-seen IDs, while the payload object for that position corresponds to the last-seen duplicate. Since the record batch request only needs one representative per ID, make the behavior explicit by either (a) keeping the first payload for eachid_field_value(do not overwrite if key already exists) or (b) if you intend to keep the last payload, also update insertion order (e.g., delete then set) so ordering and representative selection are consistent.
function uniqueRecordsById(payloads: AssociationPayload[]): AssociationPayload[] {
const uniquePayloads = new Map<string, AssociationPayload>()
for (const payload of payloads) {
uniquePayloads.set(payload.object_details.id_field_value, payload)
}
return Array.from(uniquePayloads.values())
}
packages/destination-actions/src/destinations/hubspot/upsertObject/tests/multi-label-associations.test.ts:50
- This
beforeEachis synchronous but uses thedonecallback. In Jest, usingdoneunnecessarily increases the chance of hangs if the callback is ever missed during edits. Consider removingdoneand returning nothing (or making itbeforeEach(() => { ... })) to keep the setup idiomatic and safer.
beforeEach((done) => {
testDestination = createTestIntegration(Definition)
nock.cleanAll()
done()
})
Fixes STRATCONN-6637.
The HubSpot
Custom Object V2action sent one associated-record input per association payload. A mapping that associates the same record under two association labels — e.g. a contact linked to one company as bothHUBSPOT_DEFINED:279andHUBSPOT_DEFINED:1— therefore repeated that company's id within a singlePOST /crm/v3/objects/company/batch/upsert:{"inputs": [ {"id": "6960031223", "idProperty": "ecs_object_id", "properties": {"ecs_object_id": "6960031223"}}, {"id": "6960031223", "idProperty": "ecs_object_id", "properties": {"ecs_object_id": "6960031223"}} ]}id_field_namemust be a property withhasUniqueValue = true, so HubSpot rejects a batch that resolves two inputs to the same record. The failure is non-retryable and lands after the primary record upsert has already committed, so records synced while their associated records and associations were never created.The label only matters to the subsequent
/crm/v4/associations/.../batch/createcall — it plays no part in the record upsert — but it was part of the de-duplication key, so both payloads survived into the request.A second, independent defect sits in the same de-duplication pass: its key omitted the parent record, so two records associating to the same target under the same label collapsed into one and the later one silently lost its association. Both defects fire on every affected batch — the 400 above simply hides this one, and the staging run below reproduces it directly (
monu5/monu6synced together, onlymonu6associated). Mappings with a single association label have been losing associations silently all along, with no 400 to signal it, tracked in STRATCONN-6969.Changes
1. De-duplicate the request inputs, not the payload list —
functions/hubspot-association-functions.tsreadAssociatedRecordsandupsertAssociatedRecordsnow buildinputsfromuniqueRecordsById(payloads), collapsing byid_field_value. The grouped payload list is untouched, so every label still produces its own association. Grouping guaranteesobject_typeandid_field_nameare constant within a group, soid_field_valuealone is a sufficient key.No downstream change was needed:
returnAssociatedRecordsWithIdsalready maps each response result onto every payload in the group with a matching property value, rather than relying on index alignment with the request.2. Add
from_record_idto the association de-duplication key —functions/validation-functions.tsdeDuplicateAssociationskeyed onobject_type|association_label|id_field_name|id_field_value, omitting the parent record. Two records associating to the same target under the same label collapsed into one, and the second silently lost its association. This is a pre-existing data-loss bug, and change 1 would otherwise have masked it further.The two changes depend on each other: change 2 alone puts more payloads back into the request, which is exactly what change 1 removes.
Not included
These were found while reviewing this change and are tracked separately rather than widening the diff:
upsert/update, soaddsync mode is not de-duplicated at all.Also considered and deliberately left alone:
groupPayloadschunks atMAX_HUBSPOT_BATCH_SIZEbefore de-duplication runs, so a group larger than 100 payloads can still send one record id in two concurrent requests. The uniqueness constraint prevents duplicate records being created, so the worst case is a transient conflict on a record that does not yet exist, and the refactor needed to fix it is not worth carrying on this change.Testing
__tests__/multi-label-associations.test.tscovers four cases, each asserting exact request bodies andnock.isDone():batch/upsert, two inputs inassociations/batch/create.association_sync_mode: read→ one input inbatch/read.All four fail on the pre-fix code — 1, 2 and 4 on a duplicated record input, 3 on the missing second association.
Full
hubspotsuite passes: 25 suites, 169 tests, 90 snapshots. No snapshot updates were needed — the existing tests assert exact request bodies and none of them contained duplicate association ids.yarn lintandyarn typecheckclean.No field definitions were added or changed, and no new required fields — existing subscriptions are unaffected apart from no longer sending duplicate ids.
Staging Testing
Before Fix
duplicate id in payloaderror.After fix
Security Review
type: 'password'— no field definitions were changed by this PR.🤖 Generated with Claude Code