NestboltNestbolt

@nestbolt/sortable

Quick Start

Set up the module, decorate an entity, and reorder your first record in under five minutes.

This guide walks you through the minimal setup to start managing positions on entities.

1. Register the Module

Add SortableModule.forRoot() to your root module. The module is registered globally, so you only need to import it once:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { SortableModule } from "@nestbolt/sortable";
import { Task } from "./entities/task.entity";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      // ... your database config
      autoLoadEntities: true,
      synchronize: true,
    }),
    SortableModule.forRoot(),
    TypeOrmModule.forFeature([Task]),
  ],
})
export class AppModule {}

The module registers SortableService and SortableSubscriber globally.

2. Decorate Your Entity

Add the @Sortable() decorator and an integer position column to your entity. To use the entity-level mixin methods (task.moveUp(), task.moveTo(), etc.), extend from SortableMixin():

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import { Sortable, SortableMixin } from "@nestbolt/sortable";

class TaskBase {
  id!: string;
}

@Sortable()
@Entity("tasks")
export class Task extends SortableMixin(TaskBase) {
  @PrimaryGeneratedColumn("uuid")
  declare id: string;

  @Column()
  name!: string;

  @Column({ type: "int", default: 0 })
  position!: number;
}

The base class passed to SortableMixin() declares the shape that the mixin needs -- specifically that the entity has a primary key. The mixin resolves the actual primary-key property at runtime from TypeORM metadata, so renamed PKs work.

3. Insert Rows -- Positions Are Auto-Assigned

When you insert a row without setting position, the subscriber fills it in for you using MAX(position) + 1 (scoped to the group when groupBy is configured):

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(Task) private readonly repo: Repository<Task>,
  ) {}

  async create(name: string) {
    const task = await this.repo.save(this.repo.create({ name }));
    return task; // task.position is the next available slot
  }
}

// First insert  -> position 0
// Second insert -> position 1
// Third insert  -> position 2

If you set position explicitly when creating the entity, the subscriber skips assignment and uses your value.

4. Move and Reorder

Once an entity is loaded, call the mixin methods directly:

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(Task) private readonly repo: Repository<Task>,
  ) {}

  async moveUp(id: string) {
    const task = await this.repo.findOneByOrFail({ id });
    await task.moveUp();
  }

  async moveToTop(id: string) {
    const task = await this.repo.findOneByOrFail({ id });
    await task.moveToTop();
  }

  async moveTo(id: string, position: number) {
    const task = await this.repo.findOneByOrFail({ id });
    await task.moveTo(position);
  }
}

moveTo() clamps position into the current [startPosition, maxPosition] range, so callers can't introduce gaps by asking for an out-of-range slot. Surrounding rows shift by one to make space, all inside a single transaction.

5. Use the Service Directly

If you don't want the mixin, inject SortableService and pass the entity constructor + id:

import { SortableService } from "@nestbolt/sortable";

@Injectable()
export class TasksService {
  constructor(private readonly sortable: SortableService) {}

  moveUp(id: string)     { return this.sortable.moveUp(Task, id); }
  moveDown(id: string)   { return this.sortable.moveDown(Task, id); }
  moveTop(id: string)    { return this.sortable.moveToTop(Task, id); }
  moveBottom(id: string) { return this.sortable.moveToBottom(Task, id); }
  moveTo(id: string, pos: number) {
    return this.sortable.moveTo(Task, id, pos);
  }
}

6. Bulk Reorder

When the client has reordered a list and ships you the new order as an array of ids, use reorder():

@Injectable()
export class TasksService {
  constructor(private readonly sortable: SortableService) {}

  async reorder(orderedIds: string[]) {
    await this.sortable.reorder(Task, orderedIds);
    // Tasks now sit at startPosition, startPosition + 1, ...
  }
}

reorder() validates that every id exists, runs the rewrite in a transaction, and uses a two-phase update (park above the current max, then settle) so a UNIQUE(position) constraint won't collide mid-rewrite.

7. Per-Group Ordering

If you have lists, columns, or categories that each need their own ordering, set groupBy on the decorator:

@Sortable({ groupBy: "listId" })
@Entity("tasks")
export class Task extends SortableMixin(TaskBase) {
  @PrimaryGeneratedColumn("uuid")
  declare id: string;

  @Column({ name: "list_id" })
  listId!: string;

  @Column({ type: "int", default: 0 })
  position!: number;
}

// Each list keeps its own 0, 1, 2, ... sequence:
await repo.save(repo.create({ listId: "list-1" })); // pos 0 in list-1
await repo.save(repo.create({ listId: "list-1" })); // pos 1 in list-1
await repo.save(repo.create({ listId: "list-2" })); // pos 0 in list-2

// The mixin automatically scopes moves to the entity's own group.
await task.moveUp();

When you call the service directly, pass the group value as the last argument: service.moveUp(Task, task.id, "list-1").

Next Steps

  • Configuration -- forRoot, forRootAsync, and the full SortableService API.
  • Decorator -- @Sortable() options reference, including field and groupBy.
  • Mixin -- entity-instance methods and composing with other mixins.
  • Subscriber -- how auto-position-on-insert works and when it skips.
  • Events -- listen to sortable.position-changed and sortable.reordered.