Automatic position management and ordering for NestJS entities with TypeORM.
This package provides automatic position management for NestJS that auto-assigns positions to new entities and provides move operations (up, down, to position, top, bottom) with gap-free reordering.
Once installed, using it is as simple as:
@Entity("tasks")
@Sortable()exportclassTaskextendsSortableMixin(BaseEntity){
@PrimaryGeneratedColumn("uuid")id!: string;
@Column()name!: string;
@Column({type: "int",default: 0})position!: number;}// New entities get auto-positioned: 0, 1, 2, ...// Move with: await sortableService.moveUp(Task, taskId);- Installation
- Quick Start
- Module Configuration
- Using the @Sortable() Decorator
- Auto Position on Insert
- Move Operations
- Bulk Reorder
- Group Support
- Entity Mixin
- Events
- Using the Service Directly
- Configuration Options
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
Install the package via npm:
npm install @nestbolt/sortableOr via yarn:
yarn add @nestbolt/sortableOr via pnpm:
pnpm add @nestbolt/sortableThis package requires the following peer dependencies, which you likely already have in a NestJS project:
@nestjs/common ^10.0.0 || ^11.0.0
@nestjs/core ^10.0.0 || ^11.0.0
typeorm ^0.3.0
reflect-metadata ^0.1.13 || ^0.2.0
npm install @nestjs/event-emitter # For sortable.position-changed eventsimport{SortableModule}from"@nestbolt/sortable";
@Module({imports: [TypeOrmModule.forRoot({/* ... */}),SortableModule.forRoot(),],})exportclassAppModule{}import{Sortable,SortableMixin}from"@nestbolt/sortable";
@Entity("tasks")
@Sortable()exportclassTaskextendsSortableMixin(BaseEntity){
@PrimaryGeneratedColumn("uuid")id!: string;
@Column()name!: string;
@Column({type: "int",default: 0})position!: number;}consttask1=awaitrepo.save(repo.create({name: "First"}));// position: 0consttask2=awaitrepo.save(repo.create({name: "Second"}));// position: 1consttask3=awaitrepo.save(repo.create({name: "Third"}));// position: 2// ReorderawaitsortableService.moveToTop(Task,task3.id);// Result: Third (0), First (1), Second (2)The module is registered globally — you only need to import it once.
SortableModule.forRoot({field: "position",startPosition: 0,});SortableModule.forRootAsync({imports: [ConfigModule],inject: [ConfigService],useFactory: (config: ConfigService)=>({field: config.get("sortable.field","position"),startPosition: config.get("sortable.startPosition",0),}),});The @Sortable() class decorator marks an entity for automatic position management:
@Sortable()// defaults: field="position", no grouping
@Sortable({field: "sortOrder"})// custom position field name
@Sortable({groupBy: "categoryId"})// separate ordering per group
@Sortable({field: "position",groupBy: "listId"})// both options| Option | Type | Default | Description |
|---|---|---|---|
field | string | "position" | Column name that stores the sort position |
groupBy | string | — | Column to group by for independent sort orders |
New entities automatically get the next position assigned via the TypeORM subscriber. If a position is explicitly set, the auto-assignment is skipped:
// Auto-assignedconsttask=awaitrepo.save(repo.create({name: "New Task"}));// task.position === next available position// Explicitly set — subscriber skipsconsttask=awaitrepo.save(repo.create({name: "Custom",position: 99}));// task.position === 99import{SortableService}from"@nestbolt/sortable";// Move to specific positionawaitsortableService.moveTo(Task,taskId,0);// Move up/down by one positionawaitsortableService.moveUp(Task,taskId);awaitsortableService.moveDown(Task,taskId);// Move to extremesawaitsortableService.moveToTop(Task,taskId);awaitsortableService.moveToBottom(Task,taskId);All move operations automatically shift surrounding items to maintain gap-free ordering.
Set positions for multiple entities at once by passing an ordered array of IDs:
awaitsortableService.reorder(Task,[thirdId,firstId,secondId]);// Result: Third (0), First (1), Second (2)Maintain separate sort orders per group using the groupBy option:
@Sortable({groupBy: "listId"})
@Entity("tasks")exportclassTask{
@Column()listId!: string;
@Column({type: "int",default: 0})position!: number;}Items in different groups are independently ordered:
awaitrepo.save(repo.create({name: "A",listId: "list-1"}));// position: 0awaitrepo.save(repo.create({name: "B",listId: "list-1"}));// position: 1awaitrepo.save(repo.create({name: "C",listId: "list-2"}));// position: 0// Move within groupawaitsortableService.moveUp(Task,taskId,"list-1");The SortableMixin adds convenience methods directly on your entity:
@Entity("tasks")
@Sortable()exportclassTaskextendsSortableMixin(BaseEntity){// ...}// Usageconsttask=awaittaskRepo.findOneBy({ id });awaittask.moveUp();awaittask.moveDown();awaittask.moveTo(0);awaittask.moveToTop();awaittask.moveToBottom();constpos=task.getPosition();| Method | Returns | Description |
|---|---|---|
moveUp() | Promise<void> | Move up by one position |
moveDown() | Promise<void> | Move down by one |
moveTo(position) | Promise<void> | Move to exact position |
moveToTop() | Promise<void> | Move to first position |
moveToBottom() | Promise<void> | Move to last position |
getPosition() | number | Get current position |
getPositionField() | string | Get position column name |
When @nestjs/event-emitter is installed, the package emits:
| Event | Payload | When |
|---|---|---|
sortable.position-changed | { entityType, entityId, oldPosition, newPosition } | After a position change |
sortable.reordered | { entityType, items: [{ id, position }] } | After a bulk reorder |
import{SORTABLE_EVENTS,PositionChangedEvent}from"@nestbolt/sortable";import{OnEvent}from"@nestjs/event-emitter";
@OnEvent(SORTABLE_EVENTS.POSITION_CHANGED)handlePositionChanged(event: PositionChangedEvent){console.log(`${event.entityType}#${event.entityId} moved from ${event.oldPosition} to ${event.newPosition}`);}Inject SortableService for position management:
import{SortableService}from"@nestbolt/sortable";
@Injectable()exportclassTaskService{constructor(privatereadonlysortableService: SortableService){}asyncreorderTasks(orderedIds: string[]){awaitthis.sortableService.reorder(Task,orderedIds);}}| Method | Returns | Description |
|---|---|---|
moveTo(Entity, id, position, group?) | Promise<void> | Move to exact position |
moveUp(Entity, id, group?) | Promise<void> | Move up by one |
moveDown(Entity, id, group?) | Promise<void> | Move down by one |
moveToTop(Entity, id, group?) | Promise<void> | Move to first position |
moveToBottom(Entity, id, group?) | Promise<void> | Move to last position |
reorder(Entity, orderedIds) | Promise<void> | Bulk reorder by ID array |
getMaxPosition(Entity, group?) | Promise<number> | Get highest position |
getNextPosition(Entity, group?) | Promise<number> | Get next available position |
isSortable(Entity) | boolean | Check for @Sortable metadata |
| Option | Type | Default | Description |
|---|---|---|---|
field | string | "position" | Default position column name |
startPosition | number | 0 | Starting position for new items |
npm testRun tests in watch mode:
npm run test:watchGenerate coverage report:
npm run test:covPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security-related issues, please report them via GitHub Issues with the security label instead of using the public issue tracker.
The MIT License (MIT). Please see License File for more information.