Ordered string ranks for drag-and-drop lists
Insert, move and reorder items using stable keys that sort correctly on their own - without renumbering their neighbours.
- Features
- Installing
- Example
- The two modes
- API
- Rank options
- Error types
- Rank format
- Known limitation: repeated same-position insertion
- TypeScript
- Semver
- Verification
- Contributing
- License
- Reorder items by writing one row, never renumbering the rest of the list.
- Ranks sort correctly as plain strings - no custom collation in your database.
- Zero dependencies. Ships ESM and CommonJS with type declarations for each.
- One unconditional guarantee -
prev < result < next, re-checked at runtime on every call. - Effectively infinite precision - a fixed-width integer component backed by a Base-62 minor.
- Typed errors for every failure mode, so a caller can tell a bad argument from a list that needs rebalancing.
- Batch insertion and a
rebalancehelper for seeding or repairing a list.
Using npm:
$ npm install @theyoungwolf/lexorankUsing pnpm:
$ pnpm add @theyoungwolf/lexorankUsing yarn:
$ yarn add @theyoungwolf/lexorankUsing bun:
$ bun add @theyoungwolf/lexorankThe package ships both ESM and CommonJS builds. Everything is a named export; there is no default export.
import { firstRank, rankAfter, rankBetween } from "@theyoungwolf/lexorank";const { firstRank, rankAfter, rankBetween } = require("@theyoungwolf/lexorank");Types are bundled - no @types/* package is needed.
Give every row a rank, then reorder by writing a single field.
import { firstRank, rankAfter, rankBetween, compareRanks } from "@theyoungwolf/lexorank";
// Seed a new list
let rank = firstRank(); // "0|UUUUUU:"
const tasks = ["Design", "Build", "Ship"].map((title) => {
const task = { title, rank }; // Gives firstRank to "Design" and then iterates
rank = rankAfter(rank); // Gives step-up next ranks to "Build" and "Ship"
return task;
});
// Move an item between two others - only this row is written
const moved = { title: "Review", rank: rankBetween(tasks[0].rank, tasks[1].rank) };
// Sorting a mixed bag by 'rank' yields the intended insert order
[...tasks, moved].sort((a, b) => compareRanks(a.rank, b.rank));
// Design, Review, Build, ShipA drop handler is the same idea, with null standing in for "nothing on that side":
function onDrop(column: Task[], targetIndex: number): string {
const before = column[targetIndex - 1]?.rank ?? null;
const after = column[targetIndex]?.rank ?? null;
return rankBetween(before, after);
}Persisting it touches exactly one row:
await db.task.update({ where: { id: task.id }, data: { rank: onDrop(column, 3) } });This is the most important thing to get right, and the two operations are not interchangeable.
Building a FRESH list - Use firstRank() then rankAfter()
let rank = firstRank();
for (const task of tasks) {
task.rank = rank; // Gives firstRank to first item and then iterates
rank = rankAfter(rank); // fixed step, ~86,000 appends of runway
}Placing an item relative to its neighbours - rankBetween()
const rank = rankBetween(before, after);rankAfter(x) is a pure function of x alone - it does not look at what already follows. That makes it perfect for appending, and wrong for insertions: because if you drop two tasks after the same task and use rankAfter would result in the same rank twice, colliding with the row you meant to sit before. rankBetween re-reads both neighbours, so the second drop sees the first drop's rank as its new bound and lands somewhere fresh.
The trade-off is space. Between two closed bounds rankBetween halves the remaining gap; rankAfter steps by a fixed amount. That is why both exist.
A bound of null says nothing exists on that side, so there is no neighbour to collide with and the fixed step is both safe and far cheaper. rankBetween delegates accordingly:
| Call | Equivalent to |
|---|---|
rankBetween(null, x) |
rankBefore(x) |
rankBetween(x, null) |
rankAfter(x) |
rankBetween(null, null) |
firstRank() |
rankBetween(a, b) |
midpoint, unchanged |
This matters more than it looks. Repeatedly moving items to the top of a column with rankBetween(null, first) would otherwise halve the space below each time and exhaust it after about 669 operations - a reachable number. Delegating turns that into roughly 28,590. ranksBetween (plural) follows the same rule, so ranksBetween(1, a, b) always equals rankBetween(a, b).
Every bound accepts string | null | undefined; null, undefined and "" all mean "no bound on this side".
| Export | Description |
|---|---|
firstRank() |
The initial rank for an empty list. |
rankBetween(prev, next[, options]) |
A rank sorting strictly between two bounds. |
rankAfter(to[, options]) |
Next rank above to, fixed step. Append only. |
rankBefore(to[, options]) |
Next rank below to, fixed step. Prepend only. |
ranksBetween(count, prev, next[, options]) |
count ranks between two bounds (1 to MAX_BATCH_SIZE). |
rebalance(count) |
count fresh, evenly spaced ranks. Repairs or seeds a list. |
compareRanks(a, b) |
Comparator for Array.prototype.sort. |
parseRank(value) |
Parse into a Position, or null for an open bound. |
isValidRank(value) |
True for exactly the values this API accepts. |
minorLength(rank) |
Digits in the minor component. A list-health signal. |
hasTrailingZero(value) |
True if the minor is non-canonical because it ends in 0. |
generateEntropy([length]) |
Uniformly random Base-62 string, safe to append to a rank. |
Position |
Immutable { bucket, major, minor } value object. |
Returns the first rank for an empty list, centred in the available space.
firstRank(); // "0|UUUUUU:"Returns a rank that sorts strictly between prev and next. This is the insert primitive - use it whenever an item is placed relative to its neighbours.
rankBetween("0|000000:", "0|zzzzzz:"); // "0|UUUUUU:"
rankBetween(null, first); // before everything
rankBetween(last, null); // after everythingThrows RankOrderError if prev sorts after next, DuplicateRankError if both bounds are the same rank, and InvalidRankError if either bound is malformed or non-canonical*.
*Read about Canonical Form to avoid gotchas in migrations to this system of ranking
Returns the next rank above to, stepping by a fixed structural gap. This is the append primitive: firstRank() then rankAfter() repeatedly is the intended way to build a fresh list.
rankAfter("0|UUUUUU:"); // "0|UUYgdW:"
rankAfter(null); // same as firstRank()Safe only when nothing already follows to. Calling it twice against the same anchor returns the same rank - use rankBetween to place an item before an existing one.
Returns the next rank below to, stepping by a fixed structural gap. The mirror of rankAfter, with the same caveat.
rankBefore("0|UUUUUU:"); // "0|UUQILS:"Throws RankSpaceExhaustedError if to is the absolute floor (0|000000:) - nothing sorts below it, so the list must be rebalanced.
Returns count ranks in ascending order, every one strictly between the bounds. The plural of rankBetween, applying the same bound rules.
ranksBetween(3, "0|000000:", "0|zzzzzz:");
// ["0|FUUUUU:", "0|UUUUUU:", "0|jUUUUU:"]Results are spread across the gap but not exactly evenly. The interval is divided at one character position and the remainder falls into the last gap. Every gap is billions of units wide either way, so this has no practical effect - Even-spreading is simply not a promise the function makes.
Throws BatchSizeError if count is not an integer in 1..MAX_BATCH_SIZE.
Returns count fresh, evenly spaced ranks with no minor depth. See Rebalancing.
rebalance(3);
// ["0|UUUUUU:", "0|UUYgdW:", "0|UUcsmY:"]Comparator returning -1, 0 or 1. Ranks already sort correctly as plain strings, so this is a readability convenience rather than a different ordering.
tasks.sort((a, b) => compareRanks(a.rank, b.rank));Parses a rank into an immutable Position, or returns null for an open bound. Throws InvalidRankError for anything malformed or non-canonical.
const position = parseRank("1|ABCDEF:xy");
position.bucket; // 1
position.major; // "ABCDEF"
position.minor; // ":xy" <- note the leading colon
position.toString(); // "1|ABCDEF:xy"Returns true for exactly the values the rest of this API accepts - shape and canonical form.
isValidRank("0|ABCDEF:U"); // true
isValidRank("0|ABCDEF:U0"); // false - trailing zeroReturns the number of Base-62 digits in the minor component, excluding the :. Zero means the rank lives purely in the integer space, which is where ordinary ranks sit. Use it to monitor list health.
minorLength("0|ABCDEF:"); // 0
minorLength("0|ABCDEF:UUU"); // 3Returns true if a minor is non-canonical because it ends in 0. Useful for auditing existing data before adopting this library.
column.filter((task) => hasTrailingZero(task.rank)); // rows needing migrationReturns a uniformly distributed random Base-62 string, 3 characters by default. The final character is never 0, so the result stays canonical when appended to a minor.
generateEntropy(); // "k7Q"
generateEntropy(8); // "CiXr6ZgS"Useful for separating ranks computed concurrently by different clients, which would otherwise be identical - the algorithm is deterministic, so two clients inserting between the same neighbours derive the same value. Appending widens the rank rather than preserving it, so check the upper bound afterwards; it is not a substitute for a uniqueness constraint on the column, which you should manage on your own data, i.e., in a Kanban Lane, all tasks should have a unique rank.
| Constant | Value | Meaning |
|---|---|---|
MAX_BATCH_SIZE |
60 |
Largest batch ranksBetween can place in one call. |
MAX_MINOR_LENGTH |
128 |
Default minor ceiling before RankSpaceExhaustedError. |
MAX_BUCKET |
2 |
Highest valid bucket. |
LEXORANK_REGEX |
- | The canonical shape <bucket>|<major>[:<minor>]. |
rankBetween, rankAfter, rankBefore and ranksBetween all accept an optional trailing options object.
rankBetween(before, after, { maxMinorLength: 64 });| Option | Type | Default | Description |
|---|---|---|---|
maxMinorLength |
number |
MAX_MINOR_LENGTH |
Minor digits allowed before RankSpaceExhaustedError is thrown. |
Lower values surface a runaway list sooner and bound the width of your rank column; higher values tolerate more consecutive same-position inserts between rebalances. Ordinary use stays well under either, so treat this as a tripwire rather than a capacity setting.
Every error extends LexorankError, so a single catch can cover them all. Each names a distinct cause, because callers respond to them differently.
| Error | Thrown when | Extra properties |
|---|---|---|
InvalidRankError |
A rank string is malformed or non-canonical. | .value |
RankOrderError |
prev sorts after next - arguments are probably swapped. |
.prev, .next |
DuplicateRankError |
Both bounds are the same rank; the list needs rebalancing. | .rank |
BatchSizeError |
count is not an integer in 1..MAX_BATCH_SIZE. |
.requested |
RankSpaceExhaustedError |
Nothing can exist in the requested position; rebalance. | - |
RankInvariantError |
Internal safety net. Should never fire - please report it. | .produced |
import { DuplicateRankError, RankSpaceExhaustedError } from "@theyoungwolf/lexorank";
try {
task.rank = rankBetween(before, after);
} catch (error) {
if (error instanceof DuplicateRankError || error instanceof RankSpaceExhaustedError) {
await rebalanceColumn(columnId); // then retry with fresh neighbours
} else {
throw error;
}
}- bucket (0-2) - extra headroom;
rankAfter/rankBeforeroll into the neighbouring bucket when the integer space runs out - major - fixed 6-character Base-62 integer; sequential steps of 1,000,000 leave insertion runway
- minor - Base-62 fraction, used once the integer space between two neighbours is exhausted, bounded by
MAX_MINOR_LENGTH
Because every component is fixed-width or suffix-only, ranks sort correctly as plain strings - no custom collation in your database. Ordinary ranks are 9-13 bytes.
A minor must not end in 0. :U and :U0 denote the same value but are different strings, and no rank sorts strictly between them - the gap is provably empty, since any rank above :U must extend it and every extension also sorts above :U0. Permitting both forms would create neighbours that can never be separated, so non-canonical values are rejected on parse. Ranks produced by this library are always canonical; the rule only affects hand-written or externally generated data. Use hasTrailingZero to audit an existing column.
If two rows share a rank there is no order between them, so nothing can sit between them either. Both rankBetween and ranksBetween reject this with DuplicateRankError rather than returning a value that would sort after both bounds - a silent misplacement in the UI, with the underlying data problem left in place.
Duplicate ranks are a data-integrity issue, not a transient condition: catch the error, rebalance, retry.
This keeps one unconditional guarantee: every rank this library returns sorts strictly after prev, strictly before next, and strictly after the rank before it. There are no exemptions, and the result is re-checked against its bounds on every call.
Ranks between two neighbours are found by subdividing the gap. Subdivision is not free forever, so this is the one pattern that degrades.
It does not appear in ordinary use. rankAfter leaves a structural gap of 1,000,000 units, which absorbs about fifteen subdivisions before a minor digit appears at all, and that headroom regenerates as items move around. Measured over 20,000 operations on a 40-card column:
| Usage pattern | Deepest minor |
|---|---|
| Uniform random position | 5 digits |
| 50% of moves to the top | 0 digits |
| Appending to the end | 0 digits |
| Always between the same two neighbours | grows without bound |
Only the last pattern grows. Past the structural runway the minor gains roughly one digit per five insertions, so the number of consecutive same-position inserts a list tolerates is about five times maxMinorLength:
maxMinorLength |
Inserts before it throws | Widest row |
|---|---|---|
| 32 | ~160 | 41 bytes |
| 64 | ~320 | 73 bytes |
| 128 (default) | ~640 | 137 bytes |
| 256 | ~1,280 | 265 bytes |
Exceeding the limit raises RankSpaceExhaustedError.
rebalance(count) returns fresh, evenly spaced ranks with no minor depth. It is the remedy every RankSpaceExhaustedError and DuplicateRankError points at, and equally the way to seed a new list - assigning well-spaced ranks to N items is one operation either way.
It reads nothing from the existing ranks, because a rebalance discards them: position in the sorted order is all that carries over. Fetch in rank order, ask for as many ranks as you have rows, and zip:
const tasks = await db.task.findMany({ where: { columnId }, orderBy: { rank: "asc" } });
const fresh = rebalance(tasks.length);
await db.$transaction(tasks.map((task, i) => db.task.update({ where: { id: task.id }, data: { rank: fresh[i] } })));Spacing matches a list built with firstRank() and rankAfter(), so a rebalanced list is indistinguishable from a freshly built one. Spreading items across the entire space instead would buy under 2x more room between neighbours while cutting the runway past each end from roughly 27,900 appends to about 56 - a bad trade, since appending is the more common operation.
The ceiling is in the tens of thousands of items; beyond that rebalance throws rather than quietly emitting minors, because a single ordered list that large has outgrown a flat rank space.
Treat the limit as a tripwire, not a budget. minorLength lets you spot a degrading list while it is still cheap to fix:
const deepest = Math.max(...column.map((task) => minorLength(task.rank)));
if (deepest > 24) {
await rebalanceColumn(columnId); // background, before any user hits the wall
}Ordinary ranks have a minor of zero, so any column whose deepest rank runs to a couple of dozen digits is worth rebalancing whatever limit you have set. Rebalancing resets every minor to zero and restores the full structural gap.
Type declarations ship with the package for both ESM and CommonJS; no @types/* package is needed.
import { type RankInput, type RankOptions, Position, rankBetween } from "@theyoungwolf/lexorank";RankInput is the type of every bound:
type RankInput = string | null | undefined;RankOptions is the trailing options object described in Rank options.
Position is a frozen value object. Note that minor includes its leading :, and toJSON means it serialises to its rank string rather than an object:
class Position {
readonly bucket: number;
readonly major: string;
readonly minor: string; // includes the leading ":"
toString(): string;
toJSON(): string;
}
JSON.stringify({ rank: parseRank("1|ABCDEF:") }); // {"rank":"1|ABCDEF:"}Errors are classes, so instanceof narrows them:
import { LexorankError, DuplicateRankError } from "@theyoungwolf/lexorank";
try {
rankBetween(a, b);
} catch (error) {
if (error instanceof DuplicateRankError) {
console.log(error.rank); // string
} else if (error instanceof LexorankError) {
// any other failure from this library
}
}This package follows semver. While the major version is 0, minor releases may contain breaking changes; pin accordingly.
Validated by running this implementation with over 5,205 generated cases and 1,093 stateful simulation steps - repeated midpoint insertion, 400-step append/prepend chains crossing bucket rollovers, and a randomised board simulation. 302 vectors are frozen in test/fixtures/golden.json.
A property search over 280,000 random bound pairs asserts prev < result < next on every call, and every result is re-checked against its bounds at runtime.
Issues and pull requests are welcome.
pnpm install
pnpm run check # typecheck + lint + test
pnpm run build # dual ESM/CJS + .d.ts
pnpm run test:watchChanges that affect published behaviour need a changeset:
pnpm changeset
