27. Bulk operations
BulkOperationsPlugin provides bulkPost, bulkPatch and bulkDelete. Entries
run through their normal resource lifecycle, sequentially. A bulk request can
reduce network overhead; it does not turn resource writes into one batched SQL
statement or skip permissions, validation, setters and hooks.
POST/PATCH use the same output-only format and returning options as individual writes.
Results contain meta, optional indexed errors, and data for full/minimal
responses. returning: 'none' omits data. DELETE returns its summary and deleted
IDs in meta.deleted.
Runnable example
Insert the following seven JavaScript blocks into the starting script, after installing storage and before declaring resources or starting the server, on a fresh database. Later snippets in this guide are fragments illustrating existing application state.
import { BulkOperationsPlugin } from 'json-rest-api/plugins/core/bulk-operations-plugin.js'
await api.use(BulkOperationsPlugin, { maxBulkOperations: 100, defaultAtomic: true })
await api.addResource('books', {
schema: { title: { type: 'string', required: true } }
})
await api.resources.books.createKnexTable()
const created = await api.resources.books.bulkPost({
data: [{ title: 'Alpha' }, { title: 'Beta' }], format: 'plain'
})
console.log(created.data, created.meta)
The two records are returned in input order. The summary is
{ total: 2, succeeded: 2, failed: 0, atomic: true }.
For JSON:API bulk creation, pass document: { data: [...] }, where each array
entry is a JSON:API resource object with type, attributes and relationships.
Supply either the plain data array or document, never both. Input selection
does not determine the output format.
const updated = await api.resources.books.bulkPatch({
operations: created.data.map(book => ({ id: book.id, data: { title: `${book.title} revised` } })),
format: 'plain', returning: 'minimal'
})
console.log(updated.data, updated.meta)
PATCH entries select input individually: { id, data: plainValues } or
{ id, document: { data: resourceObject } }. Each entry accepts exactly one input
key; a batch may contain both kinds. format selects output for the batch.
Minimal output contains identifiers.
const partial = await api.resources.books.bulkPost({
data: [{ title: 'Gamma' }, {}], format: 'plain', atomic: false
})
console.log(partial.data, partial.errors, partial.meta)
Gamma is stored, while the second entry fails required-title validation. The
summary reports one success and one failure; errors[0].index is 1. Successful
data entries are compacted, so their array positions do not represent failed
input slots. Use the indexed errors to identify rejected inputs.
let atomicError
try {
await api.resources.books.bulkPost({
data: [{ title: 'Not retained' }, {}], format: 'plain', atomic: true
})
} catch (error) { atomicError = error }
const afterAtomicFailure = await api.resources.books.query({ format: 'plain' })
console.log(atomicError?.transactionOutcome, afterAtomicFailure.data.map(book => book.title))
The owned batch rejects with rolled-back outcome, and Not retained is absent. The earlier, separately completed calls remain stored.
const gamma = partial.data[0]
let managedError
try {
await api.transaction(async transaction => {
await api.resources.books.bulkPatch({
operations: [{ id: gamma.id, data: { title: 'Pending title' } }],
format: 'plain', returning: 'none', atomic: true, transaction
})
throw new Error('Cancel this unit')
})
} catch (error) { managedError = error }
const afterManagedRollback = await api.resources.books.get({ id: gamma.id, format: 'plain' })
console.log(managedError?.message, afterManagedRollback.title)
The outer callback rejection restores Gamma. The batch borrows the managed transaction and cannot independently commit its changes.
const deleted = await api.resources.books.bulkDelete({ ids: created.data.map(book => book.id), atomic: true })
const remaining = await api.resources.books.query({ format: 'plain' })
console.log(deleted.meta, remaining.data)
The delete summary names the two deleted IDs. Gamma remains.
Configuration Options
Pass these options directly to api.use(BulkOperationsPlugin, options):
| Option | Type | Default | Description |
|---|---|---|---|
maxBulkOperations |
positive safe integer | 100 | Maximum number of records that can be processed in a single request |
defaultAtomic |
boolean | true | Whether operations are atomic (all-or-nothing) by default |
Invalid values and unknown option names fail during installation. Remove
batchSize and enableOptimizations: they did not provide SQL batching, and the
disconnected optimization handler has been removed. Do not wrap options inside
a 'bulk-operations' key. See the migration steps.
Failures and diagnostics
Atomic failures preserve the original error if rollback also rejects. The
outer batch attempts rollback once for an unfinished transaction it owns and stores
secondary rollback errors in the supplied context’s cleanupErrors array;
context.error retains the original failure. Both diagnostic fields reset on
the next bulk call. A rejected rollback does not confirm that data was restored.
Managed completion follows the transaction contract.
Child cleanup diagnostics are now available on the batch context, including
warnings from successful entries. Each has a zero-based bulkIndex alongside
its phase, original error and any file information. For example:
const context = {};
const result = await api.resources.books.bulkPatch({
operations: [{ id: '42', data: { title: 'Updated title' } }],
format: 'plain',
atomic: false
}, context);
for (const diagnostic of context.cleanupErrors || []) {
console.error(diagnostic.bulkIndex, diagnostic.phase, diagnostic.error);
}
The batch response keeps its existing indexed primary errors; secondary cleanup
errors are retained on the context. meta.failed counts rejected calls, not
rolled-back writes: an afterCommit hook can fail after its data is stored.
Remaining child uploads are retained in context.fileHandlingUploads, also
tagged with bulkIndex and retaining their storage and transaction references.
They can include a failed deletion or committed files whose tracking-release
hook did not run. Do not delete or retry them solely because they are tracked.
Reusing the batch context clears old error diagnostics and keeps unresolved
upload tracking without copying it into new children. Write errors expose
transactionOutcome; managed completion-chain diagnostics also identify
operationIndex, scopeName and method. Broader integration remains in progress.
Version conditions
For a resource with an explicit stored versionField, bulk PATCH and DELETE
accept expectedVersions: one revision token for each entry, in the same order
as operations or ids. Omit the entire array for unconditional writes. Each
token must be a non-empty string of at most 128 characters; null entries and
partially conditional arrays are not supported. The library validates the
array before starting any child operation, including in non-atomic mode.
const current = await api.resources.books.get({ id: '42', format: 'plain' })
await api.resources.books.bulkPatch({
operations: [{ id: current.id, data: { title: 'Updated title' } }],
expectedVersions: [current.revision],
format: 'plain'
})
Use the revision field selected by your resource configuration. It belongs in
expectedVersions, not in the submitted attributes. Successful updates rotate
the revision even when no conditions were supplied. Read full responses or
fetch the record again to obtain its new revision.
A stale condition raises REST_API_VERSION_CONFLICT. An owned atomic batch
rolls back all its changes. A non-atomic batch retains successful children and
reports the failed child’s index and transaction outcome in errors. Caller
transactions retain their normal ownership rules below. Missing or inaccessible
rows retain the single-write not-found behavior.
HTTP bulk PATCH/DELETE use the same expectedVersions property in the request
body, alongside operations or ids. Atomic version conflicts map to HTTP 409;
malformed condition arrays map to 422. These are body conditions, not HTTP
If-Match validators. Bulk creation rejects revision conditions, and all bulk
methods reject the singular expectedVersion option.
Transaction ownership
Pass transaction in the first argument to bulkPost, bulkPatch or bulkDelete,
with atomic: true. Each entry uses that same transaction, including its
relationship changes and full-response reads. The bulk call neither commits nor
rolls it back. Other calls and raw SQL can join the same transaction.
await api.transaction(async transaction => {
return api.resources.books.bulkPatch({
operations: [{ id: '42', data: { title: 'Updated title' } }],
format: 'plain',
returning: 'minimal',
atomic: true,
transaction
})
})
The managed helper rolls back the whole unit after callback rejection or a participating write/observed SQL failure, even when caught. A rejected batch can leave changes pending until the helper finishes; its individual operations do not complete the transaction or create savepoints. Raw transactions and child savepoints cannot be passed to library writes. Raw SQL can join the managed callback through its actual Knex handle.
Without a supplied transaction, an atomic batch owns one transaction for all
entries; a non-atomic batch lets each entry own its transaction. Passing a
transaction with atomic: false rejects before any child or SQL runs. This also
applies when defaultAtomic is false: explicitly select atomic: true to join
an outer transaction. A completed transaction rejects through the normal write
path and is never silently replaced with a new one.
Managed completion defers enlisted completion chains to the owner. See managed transactions and transaction outcomes for file/event integration, completion failures and uncertain outcomes.
HTTP API Usage
Express and Fastify expose bulk endpoints. Install the connector and bulk
plugin before declaring resources, then mount/start the application.
Authenticated transport context reaches every ordinary resource write and its
permission checks. HTTP always selects JSON:API/full responses: POST returns
201, PATCH and DELETE return 200 with the batch summary. atomic query values
must be exactly true or false; malformed values produce 422 without writes.
The optional plugin reserves /RESOURCE/bulk for these methods, including when
the resource accepts opaque IDs. This bulk protocol is not an implementation of
the JSON:API Atomic Operations extension.
The bulk HTTP request bodies are:
| Method and path | Body fields |
|---|---|
POST /api/books/bulk |
data: array of JSON:API resource objects |
PATCH /api/books/bulk |
operations: array of { id, data }, where data is a resource object |
DELETE /api/books/bulk |
ids: array of resource IDs |
For example:
{
"operations": [
{ "id": "1", "data": { "type": "books", "id": "1", "attributes": { "title": "Revised" } } }
]
}
Select ?atomic=false for independent child transactions, or omit it for the
configured default. Non-atomic error summaries require application handling;
a successful HTTP status does not mean every child call succeeded. In
particular, inspect each error’s transaction outcome before retrying a mutation
that may already have committed. See transaction outcomes.