NestboltNestbolt

@nestbolt/sortable

Configuration

SortableModule.forRoot()/forRootAsync() options and the full SortableService API.

forRoot

SortableModule.forRoot() registers SortableService and the SortableSubscriber globally:

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

@Module({
  imports: [SortableModule.forRoot()],
})
export class AppModule {}

Options

OptionTypeDefaultDescription
fieldstring"position"Default position column property name used when @Sortable() doesn't override it.
startPositionnumber0First position value assigned to a new row.
globalbooleantrueWhether the module is registered as global. Set to false to scope it explicitly.
SortableModule.forRoot({
  field: "position",
  startPosition: 0,
});

Per-entity overrides defined via @Sortable({ field }) take priority over the module-level default.

Identifier Validation

Both field (on the module options and @Sortable()) and groupBy (on @Sortable()) are validated against /^[A-Za-z_][A-Za-z0-9_]*$/ before use. Invalid identifiers throw immediately at decoration / module-init time, so untrusted strings can't reach the SQL layer.

forRootAsync

Use forRootAsync() when you need module setup to depend on other providers (e.g., ConfigService):

import { ConfigModule, ConfigService } from "@nestjs/config";
import { SortableModule } from "@nestbolt/sortable";

@Module({
  imports: [
    ConfigModule.forRoot(),
    SortableModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        field: config.get("sortable.field", "position"),
        startPosition: config.get("sortable.startPosition", 0),
      }),
    }),
  ],
})
export class AppModule {}

forRootAsync Options

OptionTypeDescription
importsModuleMetadata["imports"]Modules to import for dependency injection.
injectArray<InjectionToken | OptionalFactoryDependency>Providers to inject into the factory function.
useFactory(...args) => SortableModuleOptions | Promise<SortableModuleOptions>Factory function that returns the options.
globalbooleanWhether the module is registered as global (default true).

SortableService API

SortableService is available via dependency injection after module registration:

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

All mutation methods take the entity constructor as the first argument so the service can resolve the position column, primary key, and group-by column for that entity from TypeORM metadata. They all accept an optional final EntityManager argument so callers can run them inside an outer transaction.

moveTo(Entity, id, position, groupValue?, manager?)

Moves the entity with id to the given position. Surrounding rows shift by one to make space. The target is clamped into [startPosition, maxPosition] so callers can't introduce gaps:

await sortable.moveTo(Task, task.id, 0);                 // top
await sortable.moveTo(Task, task.id, 5);                 // 5 (or maxPosition if smaller)
await sortable.moveTo(Task, task.id, -10);               // clamped to startPosition
await sortable.moveTo(Task, task.id, 0, "list-1");       // scoped to a group

Runs inside a transaction. Emits sortable.position-changed.

moveUp(Entity, id, groupValue?, manager?)

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

await sortable.moveUp(Task, task.id);

Runs inside a transaction. Emits sortable.position-changed if the position actually changed.

moveDown(Entity, id, groupValue?, manager?)

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

await sortable.moveDown(Task, task.id);

Runs inside a transaction. Emits sortable.position-changed if the position actually changed.

moveToTop(Entity, id, groupValue?, manager?)

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

await sortable.moveToTop(Task, task.id);

Emits sortable.position-changed.

moveToBottom(Entity, id, groupValue?, manager?)

Moves the entity to the current maxPosition:

await sortable.moveToBottom(Task, task.id);

Runs inside a transaction. Emits sortable.position-changed.

reorder(Entity, orderedIds, groupValue?, manager?)

Settles a list of rows to consecutive positions starting at startPosition, in the order given:

await sortable.reorder(Task, [c.id, a.id, b.id]);
// Task c -> startPosition
// Task a -> startPosition + 1
// Task b -> startPosition + 2

Validation:

  • Every id in orderedIds must exist; otherwise the call throws and nothing is written.
  • When groupBy is configured and groupValue is supplied, every row must belong to that group; otherwise the call throws.
  • When groupBy is configured and groupValue is not supplied, all rows must belong to the same group (which is then inferred); a multi-group batch throws.

Runs inside a transaction with a two-phase write (park above the current max, then settle) so a UNIQUE(position) or UNIQUE(group, position) constraint won't collide mid-rewrite. Emits sortable.reordered.

getMaxPosition(Entity, groupValue?, manager?)

Returns the largest position value currently in the table (scoped to groupValue when supplied), or startPosition - 1 when the table / group is empty:

const max = await sortable.getMaxPosition(Task);
const max = await sortable.getMaxPosition(Task, "list-1");

getNextPosition(Entity, groupValue?, manager?)

Returns getMaxPosition(...) + 1. The subscriber uses this to fill in position on insert:

const next = await sortable.getNextPosition(Task);

getFieldName(Entity)

Returns the entity property used for the position column, resolving @Sortable({ field }) first, then the module-level field, then the literal "position":

sortable.getFieldName(Task); // "position"

getGroupBy(Entity)

Returns the entity property used for grouping, or null if the entity has no groupBy:

sortable.getGroupBy(Task);          // null
sortable.getGroupBy(GroupedTask);   // "listId"

getStartPosition()

Returns the resolved startPosition (defaults to 0):

sortable.getStartPosition(); // 0

isSortable(Entity)

Returns true if the entity class is decorated with @Sortable():

sortable.isSortable(Task);             // true
sortable.isSortable(SomeOtherEntity);  // false

getPrimaryKeyProperty(Entity)

Returns the entity property name for the single primary-key column. Throws if the entity has zero or more than one primary key column:

sortable.getPrimaryKeyProperty(Task); // "id"

This is what the mixin uses to look up the row's id without hardcoding id.

getOptions()

Returns the resolved SortableModuleOptions passed to forRoot() / forRootAsync():

const options = sortable.getOptions();

Static: SortableService.getInstance()

Returns the active SortableService instance, or null if the module hasn't been initialized. This is what the mixin and subscriber use internally to bridge entity-side calls to the service:

const service = SortableService.getInstance();

You normally don't need to call this directly -- prefer dependency injection or the mixin methods.

Running Inside an Outer Transaction

Every move and reorder method accepts a final EntityManager argument. Pass your own manager when the operation should join an outer transaction:

await dataSource.transaction(async (manager) => {
  await otherWork(manager);
  await sortable.moveTo(Task, task.id, 0, undefined, manager);
  await moreWork(manager);
});

When no manager is supplied, the service opens its own transaction via dataSource.transaction(...).