Update
data is always an array, even for a single record — the response shape never has to differ between updating one record and updating a hundred. Only the fields you send change; a caller without update on a given record gets a real 403 capability.forbidden for that entry, not a silently ignored write. Each entry in the response reports its own status — one record failing never blocks the others in the same request.
POST /blog-posts/update
Authorization: Bearer <token>
Content-Type: application/json
{
"data": [
{
"id": "cxmpl00000000000000000009",
"title": "Hello world (v2)"
}
]
}Body fields
Section titled “Body fields”| Field | Type | What it does |
|---|---|---|
data |
array | Each entry: id plus the fields to change |
data[].relations |
object | See below — attach/detach/sync a belongsToMany relation in the same call |
Relations: attach, detach, sync
Section titled “Relations: attach, detach, sync”A resource whose blueprint declares a relations entry (a belongsToMany relation through a pivot table) accepts an extra relations key per entry, alongside its own fields. attach adds to the pivot, detach removes from it, sync replaces the whole set in one call. sync is never combined with attach/detach in the same request — “replace with exactly this set” and “add/remove from whatever is there” are different intents that would otherwise race on the same pivot row.
Attaching or syncing requires the resource’s attach:{relation} capability, detaching (including the detach half of a sync) requires detach:{relation} — both checked on the record being updated, distinct from the plain update capability. Being able to edit a record’s own fields does not automatically mean being able to attach or detach whatever it is linked to.
The response echoes the relation’s resolved state — the full list of related ids after the mutation, not just the ones you sent (see the “Attacher un tag” scenario above).
The real generated handler, from examples/blog-post/blog-post.controller.ts:
@Post('update')
@Capability(canUpdateAnyBlogPost)
async update(
@Req() req: RequestWithUser,
@Body(new ZodValidationPipe(updateBlogPostRequestSchema))
body: UpdateBlogPostRequestBody,
) {
const subject = subjectOf(req.user);
const loaded = await this.loadAndAuthorize(
body.data.map((item) => item.id),
subject,
(s, record) => canUpdateBlogPost(s, record as never),
);
const entries = loaded.map((entry, index): BatchEntry<unknown> => {
if (!entry.ok) {
return { index, id: entry.id, failure: entry.error };
}
const { id: _id, relations, ...data } = body.data[index]!;
return {
index,
id: entry.id,
run: async () => {
const updated = await this.blogPosts.update(entry.record, data);
const relationResults: Record<string, string[]> = {};
if (relations?.tags) {
const { attach, detach, sync } = relations.tags;
if ((attach && attach.length > 0) || sync) {
const decision = canAttachTagsToBlogPost(subject, entry.record);
if (!decision.allowed) {
throw new CapabilityForbiddenException(decision);
}
}
if ((detach && detach.length > 0) || sync) {
const decision = canDetachTagsFromBlogPost(subject, entry.record);
if (!decision.allowed) {
throw new CapabilityForbiddenException(decision);
}
}
relationResults.tags = await this.blogPosts.syncTags(
entry.record,
relations.tags,
);
}
return { ...toBlogPostView(updated), ...relationResults };
},
};
});
const { results, meta } = await this.runBatch(entries, body.atomic);
return ok(results, meta);
}