31. Positioning
The positioning plugin assigns fractional string keys for custom ordering within groups, such as sortable lists and drag-and-drop interfaces.
Position allocation is coordinated by the database and participates in the resource operation’s managed transaction. Separate-connection tests cover concurrent inserts, moves, group changes, rollback and earlier transaction snapshots on PostgreSQL and MySQL, in both ordinary and canonical storage. SQLite reports a busy/snapshot conflict when another connection holds its write lock; it does not silently allocate the same key.
Setup
Install fractional-indexing alongside the ordinary Knex dependencies. The plugin
is available through its package subpath, rather than the root exports:
import { JsonRestApi, RestApiPlugin, RestApiKnexPlugin } from 'json-rest-api'
import { PositioningPlugin } from 'json-rest-api/plugins/core/rest-api-positioning-plugin.js'
// knex is application-owned; use bytewise position-column/database collation.
const api = new JsonRestApi({ name: 'ordered-tasks' })
await api.use(RestApiPlugin)
await api.use(RestApiKnexPlugin, { knex })
await api.use(PositioningPlugin, { filters: ['status'], autoIndex: false })
await api.addResource('tasks', {
schema: {
title: { type: 'string', required: true },
status: { type: 'string', required: true, search: true },
position: { type: 'string', max: 255, maxLength: 255, search: true },
beforeId: { type: 'string', virtual: true }
}
})
await api.resources.tasks.createKnexTable()
Declare the position field yourself. The plugin validates its presence; it does not add the field or migrate an existing database. Install the plugin before registering participating resources. Each resource not excluded by configuration must also declare the grouping fields.
Validation uses the compiled schema after schema:enrich, so a setup hook may
supply the position field. Its final definition must be a string without a setter.
Installation creates the internal json_rest_api_positioning_locks table when
it is absent. It stores one coordinator row per physical resource, including
the tenant/resource identity for canonical storage. Provision that table during
deployment if the runtime database account cannot create tables:
await knex.schema.createTable('json_rest_api_positioning_locks', table => {
table.string('resource', 64).primary()
})
The plugin then needs ordinary read/write access to the table. Keep the table and its rows while applications are running; removing a live coordinator would allow writers to use different locks.
Resource calls
const first = await api.resources.tasks.post({
data: { title: 'First task', status: 'todo' }
})
const last = await api.resources.tasks.post({
data: { title: 'Last task', status: 'todo', beforeId: null }
})
await api.resources.tasks.post({
data: { title: 'Middle task', status: 'todo', beforeId: last.id }
})
await api.resources.tasks.patch({
id: last.id,
data: { beforeId: first.id }
})
beforeId identifies another item in the destination group. null means the end
of that group, and 'FIRST' means its beginning. New records without beforeId
use defaultPosition. Updates within the same group retain their position when
beforeId is omitted; changing a grouping field allocates a position in the
destination group using defaultPosition. An explicit beforeId chooses a
different placement. PUT creates and replacements follow the same rules.
A self-targeted move within the same group keeps its position. Missing targets and targets outside the destination group fall back to appending. Explicit targets pass through the resource’s normal GET visibility and permission checks; a permission error rejects the write. Manual position values are ignored.
The plugin supplies position sorting when a query has no explicit sort. Position
keys are strings, not numbers. Use a bytewise collation for the stored position
column so ordinary queries display their intended order: PostgreSQL C, MySQL
utf8mb4_bin, or SQLite BINARY, for example. The allocator uses explicit binary
comparisons, but ordinary sorting and cursor predicates still use the column’s
database collation. A case-insensitive collation can display or paginate the
wrong order. Do not substitute arbitrary integer strings for valid fractional keys.
Grouping separates ordering sequences; it does not provide authorization.
Use row policies for mandatory visibility restrictions.
HTTP input
The connectors accept JSON:API documents. A request to move an existing task
uses PATCH /api/tasks/42 with Content-Type: application/vnd.api+json:
{
"data": {
"type": "tasks",
"id": "42",
"attributes": { "beforeId": "17" }
}
}
Plain records belong in programmatic data arguments; they are not the
HTTP document contract.
Configuration and limits
| Option | Default | Behavior |
|---|---|---|
field |
position |
Declared string field holding the ordering key |
filters |
[] |
Fields defining independent position groups |
excludeResources |
['system_migrations', 'system_logs'] |
Resources exempt from positioning |
beforeIdField |
beforeId |
Request attribute containing the placement target |
defaultPosition |
last |
Default placement for new records; first is also supported |
strategy |
fractional |
The implemented strategy; integer rejects |
autoIndex |
true |
Attempts an ordinary-table index during resource registration |
Automatic index creation skips canonical storage and tables that do not yet
exist. Failures are logged rather than aborting setup. Manage required indexes
through reviewed schema migrations; do not assume
that enabling autoIndex created an index.
api.positioning.getConfig() returns the configuration, and
api.positioning.isEnabled(resourceName) reports whether a registered resource
participates. The old no-op api.positioning.reorder() helper has been removed;
use resource PATCH operations or atomic bulk PATCH.
The formerly inert rebalanceThreshold option now rejects.
There is no automatic rebalancing. Keys that exceed the tighter of the schema’s
max and maxLength (default physical length: 255) reject before storage, so a
database cannot silently truncate a newly allocated key. The
rebalancePositions() utility calculates replacement keys in memory; it does
not persist them. getUnpositionedItems() and assignInitialPositions() support
offline data migrations, using actual fractional-key validation.
Concurrency and transaction boundaries
Positioned POST/PUT/PATCH operations lock one coordinator row before processing
the request. All position groups within that resource share the lock. This
deliberately serializes positioned writes to keep empty groups and moves between
groups safe without multiple group locks. The lock lasts until the owning
transaction commits or rolls back, including when the operation borrows an
api.transaction() handle. Keep those transactions short.
PostgreSQL and MySQL wait for the earlier writer and then allocate against current rows. PostgreSQL repeatable-read transactions with an older snapshot can reject with a serialization conflict; SQLite can reject with a busy/snapshot conflict. Deadlocks, timeouts and connection failures remain ordinary transaction failures. No hooks or external effects are replayed automatically. When an application retries a failed move, it must start a fresh operation/transaction.
The coordination guarantee covers writers using this plugin against the same database and lock table. Direct SQL imports, manual rebalancing and other writers must not change positions concurrently. Do not add a setter to the managed position field or change position/group fields after allocation. The plugin retains normal resource permissions, validation and commit/rollback handling.
For existing data, plan a separate migration with valid keys, explicit ordering and database-appropriate collation. Back up data and inspect the result before enabling the plugin; registration does not convert existing positions or repair duplicate keys. Existing groups must start with valid, distinct keys.