NestboltNestbolt

@nestbolt/sortable

Decorator

@Sortable() decorator options for configuring the position field and per-group ordering.

The @Sortable() decorator marks a TypeORM entity as sortable. It stores a small piece of metadata that SortableService and SortableSubscriber read to decide which column holds the position, and which (optional) column scopes per-group ordering.

Basic Usage

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

@Sortable()
@Entity("tasks")
export class Task {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

  @Column()
  name!: string;

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

With no options, the entity uses the module-level field (default "position") and no grouping.

Options Reference

OptionTypeDefaultDescription
fieldstringModule-level field (default "position")Entity property name that holds the integer position.
groupBystring--Entity property name to scope ordering by. Each distinct value forms an independent sequence.

Both values are validated against /^[A-Za-z_][A-Za-z0-9_]*$/ at decoration time. An invalid identifier throws immediately, before metadata is set.

Custom Position Field

Set field when the entity stores its position in a column other than position:

@Sortable({ field: "sortOrder" })
@Entity("articles")
export class Article {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

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

field is the entity property name, not the database column name. The service maps it to the database column via TypeORM metadata when building raw SQL fragments, so you're free to use camelCase property names with snake_case database columns.

Per-entity overrides take priority over the module-level field. Entities without an explicit override fall back to the module default.

Per-Group Ordering

Set groupBy to keep a separate position sequence per group value:

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

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

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

Inserts and moves inside list-1 do not affect the sequence in list-2:

await repo.save(repo.create({ listId: "list-1" })); // list-1 -> pos 0
await repo.save(repo.create({ listId: "list-1" })); // list-1 -> pos 1
await repo.save(repo.create({ listId: "list-2" })); // list-2 -> pos 0
await repo.save(repo.create({ listId: "list-1" })); // list-1 -> pos 2

The mixin automatically reads the entity's group value when calling moves, so task.moveUp() always stays scoped to the entity's own group. When calling the service directly, pass the group as the last positional argument:

await sortable.moveUp(Task, task.id, task.listId);
await sortable.moveTo(Task, task.id, 0, "list-1");

Combined Options

@Sortable({ field: "sortOrder", groupBy: "categoryId" })
@Entity("products")
export class Product { /* ... */ }

How Metadata is Resolved

When the service performs an operation on an entity, it resolves the position field like this:

  1. If @Sortable({ field: "..." }) was provided on the entity, that string is used.
  2. Otherwise, the field from SortableModule.forRoot({ field }) is used.
  3. Otherwise, the literal "position" is used.

groupBy is only ever read from @Sortable({ groupBy }) -- there's no module-level default.

If the entity is missing the @Sortable() decorator entirely, SortableService.isSortable() returns false, the subscriber skips it on insert, and calling service methods on it will still execute SQL against the resolved column -- so make sure your entity has one.

Composing with Other Decorators

@Sortable() is a pure metadata decorator with no runtime side effects, so it composes cleanly with any TypeORM or other Nestbolt decorators:

import { HasMedia } from "@nestbolt/medialibrary";
import { Sluggable } from "@nestbolt/sluggable";
import { SoftDeletable } from "@nestbolt/soft-delete";
import { Sortable } from "@nestbolt/sortable";

@Sortable({ groupBy: "boardId" })
@Sluggable({ from: "name" })
@SoftDeletable()
@HasMedia({ modelType: "Card" })
@Entity("cards")
export class Card { /* ... */ }

Order between Nestbolt decorators does not matter.