Align campaign view column positions with the standard layout (#23496)
Follow-up to #23493 (merged). Now based on `main`. ## Problem `AddMessageCampaignNameFieldCommand` places the new `name` column below the lowest existing position when it is the label identifier (`min - 1`). That satisfies `isViewFieldInLowestPosition` once, but does not hold: `viewField.position` is compared by the standard-application sync (`position: { toCompare: true }`), so the stored position is pulled back to the standard one and re-fails `validateLabelIdentifierFieldMetadataIdFlatViewField`, throwing `WorkspaceMigrationBuilderException` on every sync. That sync runs during `WorkspaceManagerService.init`, which is what `ActivateWorkspace` calls. cubic flagged this on the original PR: [discussion_r3653452416](https://github.com/twentyhq/twenty/pull/23188#discussion_r3653452416). ## Confirmed against prod Queried the workspace failing in Sentry ([TWENTY-SERVER-JJ2](https://twenty-v7.sentry.io/issues/7639768673/)): the `allMessageCampaigns` view has no `name` column at all, `subject` at position `0` (still the label identifier), and every column at the old layout. The activation sync tries to create `name` at standard position `0` while repointing the label identifier in the same batch, ties with `subject` at `0`, and throws. Exactly the mechanism this PR fixes. ## Change A `2-25` workspace command (`upgrade:2-25:align-message-campaign-view-field-positions`, timestamp `1785332560000`) that aligns the `allMessageCampaigns` columns to the standard layout, so the sync has nothing left to change: - Standard columns take their standard positions (`subject 0→1`, `status 1→2`, …), freeing slot `0` for `name`. - Columns the standard application does not know about keep their relative order and move above the standard ones. - Updates run in two migration passes: everything else first, then the lowest-target column alone. The migration builder validates updates one at a time against optimistic maps it mutates as it goes, so a single batch is order-dependent (caught by Greptile below); two passes keep every intermediate state valid. Placed in `2-25/` rather than `2-26/` so it can ship in a 2.25.x patch — the failures are on `v2.25.0` instances. This is why `server-previous-version-upgrade-mutation-guard` is red: it needs the `ci:allow-previous-version-upgrade-mutation` label (same as #23493). The position arithmetic and the pass-splitting are pure utils with 12 tests. ## Traced against the confirmed prod state Pass 1 moves `status`…`createdAt` up while `subject` (label identifier) stays lowest at `0`; pass 2 moves `subject` to `1`, still strictly lowest. The subsequent activation sync then creates `name` at `0` below `subject` at `1` and repoints the label identifier. Converges. Run order matters only relative to the sibling command from #23493: removal (`…550000`) sorts before alignment (`…560000`), which is correct.
This commit is contained in:
+2
@@ -7,6 +7,7 @@ import { AddMessageCampaignComposerTabCommand } from 'src/database/commands/upgr
|
||||
import { ConfigureMessageCampaignCommandMenuCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785229960000-configure-message-campaign-command-menu.command';
|
||||
import { AddMessageCampaignNameFieldCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785229970000-add-message-campaign-name-field.command';
|
||||
import { RemoveMessageCampaignNavigationMenuItemCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785332550000-remove-message-campaign-navigation-menu-item.command';
|
||||
import { AlignMessageCampaignViewFieldPositionsCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785332560000-align-message-campaign-view-field-positions.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
@@ -28,6 +29,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
ConfigureMessageCampaignCommandMenuCommand,
|
||||
AddMessageCampaignNameFieldCommand,
|
||||
RemoveMessageCampaignNavigationMenuItemCommand,
|
||||
AlignMessageCampaignViewFieldPositionsCommand,
|
||||
],
|
||||
})
|
||||
export class V2_25_UpgradeVersionCommandModule {}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { computeViewFieldPositionsAlignedToStandard } from 'src/database/commands/upgrade-version-command/2-25/utils/compute-view-field-positions-aligned-to-standard.util';
|
||||
import { splitViewFieldPositionUpdates } from 'src/database/commands/upgrade-version-command/2-25/utils/split-view-field-position-updates.util';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const ALL_MESSAGE_CAMPAIGNS_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
STANDARD_OBJECTS.messageCampaign.views.allMessageCampaigns
|
||||
.universalIdentifier;
|
||||
|
||||
@RegisteredWorkspaceCommand('2.25.0', 1785332560000)
|
||||
@Command({
|
||||
name: 'upgrade:2-25:align-message-campaign-view-field-positions',
|
||||
description:
|
||||
'Align the all campaigns view columns with the standard layout so the name label identifier sits strictly first and the standard-application sync stops trying to move it back',
|
||||
})
|
||||
export class AlignMessageCampaignViewFieldPositionsCommand extends ProvisionedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const { flatViewMaps, flatViewFieldMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatViewMaps',
|
||||
'flatViewFieldMaps',
|
||||
]);
|
||||
|
||||
const existingView =
|
||||
flatViewMaps.byUniversalIdentifier[
|
||||
ALL_MESSAGE_CAMPAIGNS_VIEW_UNIVERSAL_IDENTIFIER
|
||||
];
|
||||
|
||||
if (!isDefined(existingView)) {
|
||||
this.logger.log(
|
||||
`All campaigns view does not exist for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
||||
computeTwentyStandardApplicationAllFlatEntityMaps({
|
||||
now: new Date().toISOString(),
|
||||
workspaceId,
|
||||
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
||||
});
|
||||
|
||||
const standardPositionByUniversalIdentifier = Object.fromEntries(
|
||||
Object.values(
|
||||
standardAllFlatEntityMaps.flatViewFieldMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(viewField) =>
|
||||
viewField.viewUniversalIdentifier ===
|
||||
ALL_MESSAGE_CAMPAIGNS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
)
|
||||
.map(({ universalIdentifier, position }) => [
|
||||
universalIdentifier,
|
||||
position,
|
||||
]),
|
||||
);
|
||||
|
||||
const existingViewFields = existingView.viewFieldUniversalIdentifiers
|
||||
.map(
|
||||
(viewFieldUniversalIdentifier) =>
|
||||
flatViewFieldMaps.byUniversalIdentifier[viewFieldUniversalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const positionUpdates = computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: existingViewFields.map(
|
||||
({ universalIdentifier, position }) => ({
|
||||
universalIdentifier,
|
||||
position,
|
||||
}),
|
||||
),
|
||||
standardPositionByUniversalIdentifier,
|
||||
});
|
||||
|
||||
const viewFieldsToUpdate = positionUpdates
|
||||
.map(({ universalIdentifier, position }) => {
|
||||
const existingViewField =
|
||||
flatViewFieldMaps.byUniversalIdentifier[universalIdentifier];
|
||||
|
||||
return isDefined(existingViewField)
|
||||
? { ...existingViewField, position }
|
||||
: null;
|
||||
})
|
||||
.filter((viewField): viewField is FlatViewField => isDefined(viewField));
|
||||
|
||||
if (viewFieldsToUpdate.length === 0) {
|
||||
this.logger.log(
|
||||
`All campaigns view columns already match the standard layout for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Aligning ${viewFieldsToUpdate.length} all campaigns view column(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { others, lowest } = splitViewFieldPositionUpdates(viewFieldsToUpdate);
|
||||
|
||||
for (const viewFieldBatch of [others, lowest]) {
|
||||
if (viewFieldBatch.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.runViewFieldUpdates({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
viewFieldsToUpdate: viewFieldBatch,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Aligned the all campaigns view columns for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async runViewFieldUpdates({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
viewFieldsToUpdate,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
viewFieldsToUpdate: FlatViewField[];
|
||||
}): Promise<void> {
|
||||
const result =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
|
||||
{
|
||||
isSystemBuild: true,
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
viewField: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: viewFieldsToUpdate,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status === 'fail') {
|
||||
this.logger.error(
|
||||
`Failed to align the all campaigns view columns:\n${JSON.stringify(result, null, 2)}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to align the all campaigns view columns for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { computeViewFieldPositionsAlignedToStandard } from 'src/database/commands/upgrade-version-command/2-25/utils/compute-view-field-positions-aligned-to-standard.util';
|
||||
|
||||
const STANDARD = {
|
||||
name: 0,
|
||||
subject: 1,
|
||||
status: 2,
|
||||
};
|
||||
|
||||
describe('computeViewFieldPositionsAlignedToStandard', () => {
|
||||
it('should return nothing when the view already matches the standard layout', () => {
|
||||
expect(
|
||||
computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: [
|
||||
{ universalIdentifier: 'name', position: 0 },
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'status', position: 2 },
|
||||
],
|
||||
standardPositionByUniversalIdentifier: STANDARD,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should shift the pre-name layout onto the standard positions', () => {
|
||||
expect(
|
||||
computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: [
|
||||
{ universalIdentifier: 'subject', position: 0 },
|
||||
{ universalIdentifier: 'status', position: 1 },
|
||||
],
|
||||
standardPositionByUniversalIdentifier: STANDARD,
|
||||
}),
|
||||
).toEqual([
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'status', position: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pull a column left below the standard position back up', () => {
|
||||
expect(
|
||||
computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: [
|
||||
{ universalIdentifier: 'name', position: -1 },
|
||||
{ universalIdentifier: 'subject', position: 0 },
|
||||
],
|
||||
standardPositionByUniversalIdentifier: STANDARD,
|
||||
}),
|
||||
).toEqual([
|
||||
{ universalIdentifier: 'name', position: 0 },
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should move unknown columns above every standard one, keeping their order', () => {
|
||||
expect(
|
||||
computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: [
|
||||
{ universalIdentifier: 'customB', position: 5 },
|
||||
{ universalIdentifier: 'subject', position: 0 },
|
||||
{ universalIdentifier: 'customA', position: 1 },
|
||||
],
|
||||
standardPositionByUniversalIdentifier: STANDARD,
|
||||
}),
|
||||
).toEqual([
|
||||
{ universalIdentifier: 'customB', position: 4 },
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'customA', position: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should leave the label identifier strictly lowest in every case', () => {
|
||||
const cases = [
|
||||
[
|
||||
{ universalIdentifier: 'subject', position: 0 },
|
||||
{ universalIdentifier: 'status', position: 1 },
|
||||
],
|
||||
[
|
||||
{ universalIdentifier: 'name', position: -1 },
|
||||
{ universalIdentifier: 'subject', position: 0 },
|
||||
],
|
||||
[
|
||||
{ universalIdentifier: 'custom', position: 0 },
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
],
|
||||
];
|
||||
|
||||
cases.forEach((existingViewFields) => {
|
||||
const updates = computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields,
|
||||
standardPositionByUniversalIdentifier: STANDARD,
|
||||
});
|
||||
|
||||
const finalPositions = existingViewFields.map(
|
||||
({ universalIdentifier, position }) =>
|
||||
updates.find(
|
||||
(update) => update.universalIdentifier === universalIdentifier,
|
||||
)?.position ?? position,
|
||||
);
|
||||
const namePosition = STANDARD.name;
|
||||
|
||||
finalPositions.forEach((position, index) => {
|
||||
if (existingViewFields[index].universalIdentifier === 'name') {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(position).toBeGreaterThan(namePosition);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nothing when the standard layout is unknown', () => {
|
||||
expect(
|
||||
computeViewFieldPositionsAlignedToStandard({
|
||||
existingViewFields: [{ universalIdentifier: 'subject', position: 0 }],
|
||||
standardPositionByUniversalIdentifier: {},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { splitViewFieldPositionUpdates } from 'src/database/commands/upgrade-version-command/2-25/utils/split-view-field-position-updates.util';
|
||||
|
||||
describe('splitViewFieldPositionUpdates', () => {
|
||||
it('should hold back the column that ends up lowest', () => {
|
||||
expect(
|
||||
splitViewFieldPositionUpdates([
|
||||
{ universalIdentifier: 'name', position: 0 },
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'status', position: 2 },
|
||||
]),
|
||||
).toEqual({
|
||||
others: [
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'status', position: 2 },
|
||||
],
|
||||
lowest: [{ universalIdentifier: 'name', position: 0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should hold it back regardless of where it sits in the input', () => {
|
||||
expect(
|
||||
splitViewFieldPositionUpdates([
|
||||
{ universalIdentifier: 'status', position: 2 },
|
||||
{ universalIdentifier: 'name', position: 0 },
|
||||
]).lowest,
|
||||
).toEqual([{ universalIdentifier: 'name', position: 0 }]);
|
||||
});
|
||||
|
||||
it('should handle negative positions', () => {
|
||||
expect(
|
||||
splitViewFieldPositionUpdates([
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
{ universalIdentifier: 'name', position: -1 },
|
||||
]).lowest,
|
||||
).toEqual([{ universalIdentifier: 'name', position: -1 }]);
|
||||
});
|
||||
|
||||
it('should pass a single update straight through', () => {
|
||||
expect(
|
||||
splitViewFieldPositionUpdates([
|
||||
{ universalIdentifier: 'subject', position: 1 },
|
||||
]),
|
||||
).toEqual({
|
||||
others: [],
|
||||
lowest: [{ universalIdentifier: 'subject', position: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty batches for no updates', () => {
|
||||
expect(splitViewFieldPositionUpdates([])).toEqual({
|
||||
others: [],
|
||||
lowest: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should never leave a column below the held-back one in the first batch', () => {
|
||||
const { others, lowest } = splitViewFieldPositionUpdates([
|
||||
{ universalIdentifier: 'a', position: 3 },
|
||||
{ universalIdentifier: 'b', position: -2 },
|
||||
{ universalIdentifier: 'c', position: 0 },
|
||||
]);
|
||||
|
||||
others.forEach(({ position }) => {
|
||||
expect(position).toBeGreaterThan(lowest[0].position);
|
||||
});
|
||||
});
|
||||
});
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
type ViewFieldPosition = {
|
||||
universalIdentifier: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
// The label identifier's column has to sit strictly below every other column of
|
||||
// its view. Placing the incoming column below the current lowest position
|
||||
// satisfies that once but does not survive: viewField.position is compared by
|
||||
// the standard-application sync, so a position that differs from the standard
|
||||
// one gets pulled back and trips the same validation again.
|
||||
//
|
||||
// Aligning the view to the standard layout leaves the sync with nothing to do.
|
||||
// Columns the standard application does not know about keep their relative
|
||||
// order and move above the standard ones, so they cannot tie with the label
|
||||
// identifier either.
|
||||
export const computeViewFieldPositionsAlignedToStandard = ({
|
||||
existingViewFields,
|
||||
standardPositionByUniversalIdentifier,
|
||||
}: {
|
||||
existingViewFields: ViewFieldPosition[];
|
||||
standardPositionByUniversalIdentifier: Record<string, number>;
|
||||
}): ViewFieldPosition[] => {
|
||||
const standardPositions = Object.values(standardPositionByUniversalIdentifier);
|
||||
|
||||
if (standardPositions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const highestStandardPosition = Math.max(...standardPositions);
|
||||
|
||||
const customUniversalIdentifiers = existingViewFields
|
||||
.filter(
|
||||
({ universalIdentifier }) =>
|
||||
standardPositionByUniversalIdentifier[universalIdentifier] === undefined,
|
||||
)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ universalIdentifier }) => universalIdentifier);
|
||||
|
||||
return existingViewFields.flatMap(({ universalIdentifier, position }) => {
|
||||
const standardPosition =
|
||||
standardPositionByUniversalIdentifier[universalIdentifier];
|
||||
|
||||
const targetPosition =
|
||||
standardPosition ??
|
||||
highestStandardPosition +
|
||||
1 +
|
||||
customUniversalIdentifiers.indexOf(universalIdentifier);
|
||||
|
||||
return targetPosition === position
|
||||
? []
|
||||
: [{ universalIdentifier, position: targetPosition }];
|
||||
});
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
type ViewFieldPosition = {
|
||||
universalIdentifier: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
// The migration builder validates updates one at a time against optimistic maps
|
||||
// it mutates as it goes, so a batch is only valid if every intermediate state is
|
||||
// valid too. Moving the column that ends up lowest at the same time as the ones
|
||||
// it has to sit below can therefore be rejected: whether it passes depends on
|
||||
// which update the builder happens to reach first.
|
||||
//
|
||||
// Applying the lowest column on its own, after the others have moved up, keeps
|
||||
// every intermediate state valid whatever order the builder picks.
|
||||
export const splitViewFieldPositionUpdates = <T extends ViewFieldPosition>(
|
||||
positionUpdates: T[],
|
||||
): { others: T[]; lowest: T[] } => {
|
||||
if (positionUpdates.length <= 1) {
|
||||
return { others: [], lowest: positionUpdates };
|
||||
}
|
||||
|
||||
const lowestPosition = Math.min(
|
||||
...positionUpdates.map(({ position }) => position),
|
||||
);
|
||||
const lowestIndex = positionUpdates.findIndex(
|
||||
({ position }) => position === lowestPosition,
|
||||
);
|
||||
|
||||
return {
|
||||
others: positionUpdates.filter((_, index) => index !== lowestIndex),
|
||||
lowest: [positionUpdates[lowestIndex]],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user