fix(twenty-server): preserve input order in createMany response (#17412)

## Summary
- The `createMany` API was returning records in arbitrary order because
`fetchUpsertedRecords` used `WHERE id IN (...)` without `ORDER BY`
- This caused flaky tests that assumed input order was preserved (e.g.,
`people-merge-many.integration-spec.ts`)
- Fixed by reordering the fetched records to match the original input
order from `generatedMaps`

## Root Cause
```typescript
// Before: No ORDER BY, so SQL returns in arbitrary order
const upsertedRecords = await queryBuilder
  .where({ id: In(objectRecords.generatedMaps.map((record) => record.id)) })
  .getMany();  // Returns in arbitrary order!
```

## Fix
```typescript
// After: Reorder results to match original input order
const orderedIds = objectRecords.generatedMaps.map((record) => record.id);
const upsertedRecords = await queryBuilder
  .where({ id: In(orderedIds) })
  .getMany();

// Preserve original input order
const recordsById = new Map(upsertedRecords.map((record) => [record.id, record]));
return orderedIds
  .map((id) => recordsById.get(id))
  .filter((record) => record !== undefined);
```

## Test plan
- [x] Run `people-merge-many.integration-spec.ts` multiple times -
passes consistently
- [x] Lint passes
This commit is contained in:
Félix Malfait
2026-01-24 13:49:48 +01:00
committed by GitHub
parent cd7c2864d2
commit 389b6ce7b2
3 changed files with 31 additions and 7 deletions
@@ -419,17 +419,25 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
flatFieldMetadataMaps,
});
const orderedIds = objectRecords.generatedMaps.map((record) => record.id);
const upsertedRecords = await queryBuilder
.setFindOptions({
select: columnsToSelect,
})
.where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
id: In(orderedIds),
})
.withDeleted()
.take(QUERY_MAX_RECORDS)
.getMany();
const orderIndex = new Map(orderedIds.map((id, index) => [id, index]));
upsertedRecords.sort(
(a, b) => (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0),
);
return upsertedRecords as ObjectRecord[];
}
@@ -71,12 +71,20 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
});
if (isDefined(args.ids) && args.ids.length > 0) {
objectRecords = (await existingRecordsQueryBuilder
const fetchedRecords = (await existingRecordsQueryBuilder
.where({ id: In(args.ids) })
.setFindOptions({
select: columnsToSelect,
})
.getMany()) as ObjectRecord[];
const orderIndex = new Map(args.ids.map((id, index) => [id, index]));
fetchedRecords.sort(
(a, b) => (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0),
);
objectRecords = fetchedRecords;
} else if (args.data && !isEmpty(args.data)) {
objectRecords = args.data;
}
@@ -137,12 +137,12 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
flatFieldMetadataMaps: context.flatFieldMetadataMaps,
});
const recordsToMerge = await context.repository.find({
const fetchedRecords = (await context.repository.find({
where: { id: In(args.ids) },
select: columnsToSelect,
});
})) as ObjectRecord[];
if (recordsToMerge.length !== args.ids.length) {
if (fetchedRecords.length !== args.ids.length) {
throw new CommonQueryRunnerException(
'One or more records not found',
CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND,
@@ -150,12 +150,20 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
);
}
const orderIndex = new Map(args.ids.map((id, index) => [id, index]));
fetchedRecords.sort(
(a, b) => (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0),
);
const recordsToMerge = fetchedRecords;
if (args.dryRun && args.selectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
flatObjectMetadataMaps: context.flatObjectMetadataMaps,
flatFieldMetadataMaps: context.flatFieldMetadataMaps,
parentObjectMetadataItem: context.flatObjectMetadata,
parentObjectRecords: recordsToMerge as ObjectRecord[],
parentObjectRecords: recordsToMerge,
relations: args.selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
@@ -168,7 +176,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
});
}
return recordsToMerge as ObjectRecord[];
return recordsToMerge;
}
private validateAndGetPriorityRecord(