Skip to main content

Mutations (atomic operators)

.mutate() opens the MutationBuilder — a sub-chain for atomic, field-level operations that map to MongoDB's update operators ($set, $inc, $push, etc.). Use mutations when you want to change specific fields without rewriting the whole document, or when you want atomic semantics for things like counters and array merges.

Mutations belong to a write terminal. On the MutationBuilder itself the terminals are .updateOne(), .updateMany(), .patchOne(), .patchMany() to execute immediately, and .asUpdateOne(), .asUpdateMany(), .asPatchOne(), .asPatchMany() when you're queueing the operation into a bulk.

Shape at a glance

await bob.flowQuery('finance/bill')
.docId('B00000048')
.mutate()
.set({ 'data.amount': 5000, 'data.currency': 'USD' })
.inc({ 'data.lineCount': 1 })
.push({ 'data.tags': 'urgent' })
.status('posted')
.updateOne()

The chain accumulates operators into the IR's mutation payload; the terminal compiles and executes.

Field-level operators

.set(dotPaths)$set

.set({ 'data.amount': 5000, 'data.currency': 'USD' })

.setData(fields, opts?)$set with auto data. prefix

Convenience: keys are automatically prefixed with data..

.setData({ amount: 5000, currency: 'USD' })
// same as .set({ 'data.amount': 5000, 'data.currency': 'USD' })

.setFlat(nested) — flatten nested → dot-notation

Useful when you have a nested object and want a precise $set that only touches leaf fields (not clobber a parent object):

.setFlat({ data: { name: 'John', address: { line1: '123 Main St' } } })
// produces $set: { 'data.name': 'John', 'data.address.line1': '123 Main St' }

.unset(...fields)$unset

Removes a field entirely:

.unset('data.tempField')
.unset('data.a', 'data.b', 'data.c')

.inc(dotPaths)$inc

Atomic numeric increment. Negative values decrement.

.inc({ 'data.lineCount': 1, 'data.totalAmount': -50 })

.multiply(dotPaths)$mul

Atomic numeric multiply. 1.10 applies a 10% increase.

.multiply({ 'data.price': 1.10 })

.min(dotPaths) / .max(dotPaths)$min / $max

Update only if the new value is smaller/larger than the existing value.

.min({ 'data.lowestPrice': 99.99 })
.max({ 'data.highestPrice': 500 })

.rename(mapping)$rename

Rename fields in place.

.rename({ 'data.oldField': 'data.newField' })

Array operators

.push(dotPaths)$push

Append to an array (no deduplication).

.push({ 'data.tags': 'urgent' })

.pull(dotPaths)$pull

Remove matching elements from an array.

.pull({ 'data.tags': 'draft' })

.pullAll(dotPaths)$pullAll

Remove every occurrence of every listed value.

.pullAll({ 'data.tags': ['draft', 'pending'] })

.addToSet(dotPaths)$addToSet

Append only if the element isn't already present (set semantics).

.addToSet({ 'data.categories': 'reviewed' })

.pop(field, direction)$pop

Pop first (-1) or last (1) element.

.pop('data.queue', 1)   // pop the last element

.slice(field, maxLength) — bounded push

Trim an array to a maximum length (implemented via $push + $slice). Use for bounded queues, recent-items lists, etc.

.slice('data.recentNotes', 10)

.updateArrayElement(arrayField, data, filter) — positional update

Update a specific element inside an array by a filter.

.updateArrayElement(
'data.lineItems',
{ quantity: 5 },
{ sku: 'ABC-123' },
)

Upsert-only operator

.setOnInsert(dotPaths)$setOnInsert

Applied only when the write triggers an insert (relevant for upsert patterns). Ignored on a plain update.

.setOnInsert({ 'data.createdBy': userId })

Status and data-type transitions

A mutation can bundle a status or data-type transition alongside field ops:

.mutate()
.set({ 'data.approvedAt': new Date() })
.status('posted')
.updateOne()

.mutate()
.setData({ paidAmount: 1000 })
.dataType('bill-payment-reconciliation-status', 'paid')
.patchOne()

These run through the same transition guards as setStatus and setDataType.

Bypassing immutability

.mutate()
.set({ 'data.amount': 5000 })
.modifyImmutable() // explicit opt-in — required when the current status is immutable
.updateOne()

Same semantics as updateOne(payload, { modifyImmutable: true }).

Terminals

The mutation chain ends with one of:

TerminalExecutes immediatelySemantics
.updateOne()yesFull-replace semantics on one doc
.updateMany()yesFull-replace semantics on all matching
.patchOne()yesMerge semantics on one doc
.patchMany()yesMerge semantics on all matching
.asUpdateOne()noConfigure for bulk
.asUpdateMany()noConfigure for bulk
.asPatchOne()noConfigure for bulk
.asPatchMany()noConfigure for bulk

The as* terminals return the parent FlowQueryBuilder, ready to be handed to bulk.add(...). See Bulk.

Realistic example

await bob.flowQuery('finance/journalItem')
.docId(docId)
.mutate()
.setData({ approvedBy: bob.flowUser.userId })
.inc({ 'data.revisionCount': 1 })
.addToSet({ 'data.history': { event: 'approved', at: new Date() } })
.unset('data.pendingApprovalToken')
.status(bob.flowQuery('finance/journalItem').statusRef().value('posted'))
.updateOne()

Every field change is atomic, validated against the schema (unless .skipValidation() is used), and participates in the current debe session.

Next: Bulk →