@nestbolt/sortable
Subscriber
How SortableSubscriber assigns positions automatically on insert, and the rules for when it skips.
SortableSubscriber is a TypeORM EntitySubscriberInterface that registers itself on the DataSource and assigns the next position to @Sortable() entities on insert. It's wired automatically when you register SortableModule.forRoot() -- you don't need to register it yourself.
What It Does
When TypeORM is about to insert a row, it fires beforeInsert on every registered subscriber. The sortable subscriber checks, in order:
- The event has an
entity(skip otherwise). - The entity's class is decorated with
@Sortable()(skip plain entities). - The entity has a constructor (skip
Object.create(null)and similar). - The position field is currently
nullorundefined(skip if the caller already set a position). SortableService.getInstance()is non-null (skip silently if the module wasn't initialized).
If all checks pass, the subscriber calls service.getNextPosition(EntityType, groupValue, event.manager) and writes the result to the entity's position field. The entity is then handed back to TypeORM, which performs the actual INSERT.
// Plain TypeORM call
const task = await repo.save(repo.create({ name: "New" }));
// Becomes (effectively)
task.position = await service.getNextPosition(Task, undefined, event.manager);
await /* TypeORM inserts the row */;In-Transaction MAX Read
The subscriber passes event.manager to getNextPosition(), so the MAX(position) read happens on the same connection as the insert. This makes the read+write pair atomic from this connection's point of view -- another connection cannot slip in a higher position between the MAX query and the insert as far as this writer is concerned.
If two connections insert into an empty group at exactly the same time, both can compute the same next = 0 and a unique constraint (if you have one) is the backstop. For workloads that need a hard guarantee against duplicate positions, add a UNIQUE(position) or UNIQUE(group, position) index -- the package's move and reorder algorithms are designed to be safe under those constraints.
When Auto-Assignment is Skipped
The subscriber leaves the position alone in these cases:
- The position is already set. If the entity has any non-null/undefined value in its position field when it reaches
beforeInsert, the subscriber skips. This lets callers seed specific positions for migrations, data imports, or tests. - The entity isn't decorated. If the entity's class doesn't have
@Sortable(), the subscriber skips. Plain TypeORM entities pass through unchanged. - The module isn't initialized. If
SortableService.getInstance()returnsnull(e.g., during a partial test bootstrap), the subscriber skips and the row is inserted with whatever default the database column provides.
Group Scoping
When @Sortable({ groupBy: "listId" }) is set, the subscriber reads the entity's listId field and passes it to getNextPosition(). Each group has its own MAX(position), so different groups never collide:
@Sortable({ groupBy: "listId" })
@Entity("tasks")
export class Task { /* ... */ }
await repo.save(repo.create({ listId: "list-1" })); // list-1 -> 0
await repo.save(repo.create({ listId: "list-1" })); // list-1 -> 1
await repo.save(repo.create({ listId: "list-2" })); // list-2 -> 0
await repo.save(repo.create({ listId: "list-1" })); // list-1 -> 2If groupBy is configured but the entity's group field is undefined at insert time, the subscriber treats the row as ungrouped (groupValue is undefined), and getNextPosition() falls back to a global MAX(position). Either set the group field explicitly before insert or rely on the column default to keep groups distinct.
Bypassing Auto-Assignment
If you need to insert a row at a specific position, set the position field explicitly:
const task = repo.create({ name: "Pinned", position: 0 });
await repo.save(task); // subscriber skips; row is inserted with position = 0Note that this can leave gaps or duplicate positions in the sequence -- the subscriber doesn't shift other rows out of the way. For controlled insertion, save without a position (so the row goes to the end), then call task.moveTo(targetPosition) to shift it into place.
Disabling the Subscriber
There's no opt-out flag for an individual entity. If you don't want auto-positioning for a specific entity, don't decorate it with @Sortable(). The subscriber only acts on decorated entities; everything else passes through untouched.
If you don't want the subscriber globally, simply don't import SortableModule -- you can still use SortableService (e.g., for migration scripts) by instantiating it manually, but the auto-positioning behavior on insert won't be active.