NestboltNestbolt

@nestbolt/sortable

Mixin

Add moveUp(), moveDown(), moveTo(), moveToTop(), moveToBottom(), getPosition(), and getPositionField() directly on your entity instances.

SortableMixin adds convenience methods to your entity class so you can move entities and read their position without injecting SortableService.

Basic Usage

Extend your entity 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 of the entity. The mixin doesn't hardcode the primary-key property name -- it asks the service for the entity's PK at call time, so renamed primary keys (customId, taskId, etc.) work without extra configuration.

Methods

moveUp()

Moves the entity up by one position. Returns silently if the entity is already at startPosition:

const task = await repo.findOneByOrFail({ id });
await task.moveUp();

moveDown()

Moves the entity down by one position. Returns silently if the entity is already at maxPosition:

await task.moveDown();

moveTo(position)

Moves the entity to the given position. Surrounding rows shift by one. The target is clamped into [startPosition, maxPosition], so out-of-range targets settle at the nearest valid slot rather than introducing gaps:

await task.moveTo(0);    // top
await task.moveTo(5);    // 5, or maxPosition if smaller
await task.moveTo(-10);  // clamped to startPosition

moveToTop()

Moves the entity to startPosition. Equivalent to moveTo(getStartPosition()):

await task.moveToTop();

moveToBottom()

Moves the entity to the current maxPosition:

await task.moveToBottom();

getPosition()

Returns the entity's current position from the in-memory instance:

task.getPosition(); // number

This reads the position field directly from the instance -- it does not hit the database. Reload the entity if you need the value after another writer has changed it.

getPositionField()

Returns the entity property name used to store the position, resolved from @Sortable({ field }) or the default "position":

task.getPositionField(); // "position"

getPosition() and getPositionField() work even when SortableModule hasn't been initialized -- they're pure metadata readers and are safe to call from unit tests or scripts.

Group-Scoped Moves

When the entity uses @Sortable({ groupBy: "listId" }), the mixin reads the entity's own group value and scopes the move to it automatically. You don't need to pass it manually:

@Sortable({ groupBy: "listId" })
@Entity("tasks")
export class Task extends SortableMixin(TaskBase) {
  @Column({ name: "list_id" })
  listId!: string;

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

const task = await repo.findOneByOrFail({ id });
await task.moveUp();    // scoped to task.listId
await task.moveTo(0);   // scoped to task.listId

If you need to move an entity into a different group, change task.listId and save it first, then call the move method.

Composing with Other Mixins

SortableMixin composes with the other Nestbolt mixins like SoftDeletableMixin, LikeableMixin, HasMediaMixin, and SluggableMixin:

import { HasMedia, HasMediaMixin } from "@nestbolt/medialibrary";
import { Sluggable, SluggableMixin } from "@nestbolt/sluggable";
import { SoftDeletable, SoftDeletableMixin } from "@nestbolt/soft-delete";
import { Sortable, SortableMixin } from "@nestbolt/sortable";

@Sortable({ groupBy: "boardId" })
@Sluggable({ from: "name" })
@SoftDeletable()
@HasMedia({ modelType: "Card" })
@Entity("cards")
export class Card extends SortableMixin(
  SoftDeletableMixin(
    SluggableMixin(
      HasMediaMixin(class {
        id!: string;
      }),
    ),
  ),
) {
  @PrimaryGeneratedColumn("uuid")
  declare id: string;

  @Column()
  name!: string;

  @Column({ default: "" })
  slug!: string;

  @Column({ name: "board_id" })
  boardId!: string;

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

  @Column({ name: "deleted_at", type: "datetime", nullable: true, default: null })
  deletedAt!: Date | null;
}

// Card now has all four mixin APIs:
// card.moveUp(), card.moveTo(0), card.getPosition()
// card.softDelete(), card.restore(), card.isDeleted()
// card.getSlug(), card.findBySlug(), card.regenerateSlug()
// card.addMedia(), card.getMedia(), card.getFirstMediaUrl()

The order of mixin wrapping doesn't matter functionally -- pick whatever reads best for your codebase.

How the Bridge Works

The mixin doesn't depend on dependency injection. Instead, it calls SortableService.getInstance() -- a static accessor that returns the active service instance after SortableModule has been initialized.

This means:

  • The methods work on entity instances loaded via TypeORM, manually constructed, or returned from any source.
  • You don't need to pass the service in -- but you do need SortableModule.forRoot() to be imported in your application module.

The mixin also asks the service for the entity's primary-key property at each call, so the entity's PK can be id, taskId, customId, etc. without changing the mixin code.

Error Handling

If SortableModule has not been initialized (e.g., you call a mixin method before the Nest application has bootstrapped), a SortableNotInitializedException is thrown by every move method:

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

try {
  await task.moveUp();
} catch (error) {
  if (error instanceof SortableNotInitializedException) {
    console.error("SortableModule has not been initialized");
  }
}

getPosition() and getPositionField() are the two methods that do not throw when the service is unavailable -- they read directly from the instance and its decorator metadata, so you can use them safely in tests or scripts.

In practice, the exception should never fire in a running app -- it's a defensive guard for tests or scripts that run outside the normal bootstrap sequence.