Optic Update — JavaScript Implementation
Combining Row Pipelines with Document Mutation
The examples in this article use the llamaverse (v2.0+). The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.
Preview Feature
The op.patch() and op.remove() plan methods described in this article are a preview feature in MarkLogic 11. Preview APIs may evolve across minor releases. Verify availability in your specific MarkLogic installation before adopting them in production. op.patchBuilder() is stable and available in MarkLogic 11; the plan-level update pipeline extends this with methods that are still stabilising.
Optic Update is one of the most useful additions in the MarkLogic 11 era. Before it arrived, many teams used Optic to identify target documents and then dropped into separate XQuery or JavaScript loops to perform the actual mutation. That worked, but it broke the flow and encouraged awkward two-step code. Optic Update improves this by letting the pipeline continue from row resolution into mutation, so selection logic, joins, and update intent stay in one coherent plan.
The capability is currently JavaScript-first. XQuery teams can still access it through xdmp:javascript-eval or dedicated Server-Side JavaScript modules. That architecture choice matters as much as the syntax itself.
The API Landscape
It helps to be precise about which parts of the API exist stably and which parts are the preview extension. In MarkLogic 11:
| API component | Status | Notes |
|---|---|---|
op.fromView(), op.fromTriples(), op.fromLexicons() | Stable | Standard Optic row sources |
op.patchBuilder() | Stable | Server-side; defines node-level patch operations |
plan.patch(docCol, patchDef) | Preview | Applies a patch definition to documents in the pipeline |
plan.remove(uriCol) | Preview | Deletes documents identified by the pipeline |
The patchBuilder has been part of MarkLogic's patching API for some time and is reliable. The plan-level patch() and remove() methods — which make the update happen as a pipeline step rather than a separate loop — are the preview extension.
Also worth noting: the plan-level deletion operation is called op.remove() (or plan.remove()), not op.delete(). Similarly, removing a node inside a document via the patch builder is also called patchBuilder.remove(path). These are different operations with the same vocabulary.
The Core Design: Rows Flow Into Mutation
Optic is a row pipeline. Update does not change that fundamental model. It extends the places where a plan can terminate. A read-only plan ends with .result() or .toArray(). An update-capable plan ends with a mutating step such as .patch() or .remove(). Everything before that step is the same row resolution you already know.
The mental model is: identify the rows, then act on the documents those rows represent. You resolve which documents to touch using efficient indexed row operations, then apply a focused change. That is what makes Optic Update cleaner than the traditional two-step pattern.
op.fromView() as the Starting Point
op.fromView() is the most natural entry point for Optic Update when your documents are already modelled in TDE. The llamaverse uses a TDE template that exposes the llama JSON documents as rows in the llamaverse.llamas view. The view has columns including id, name, breed, heightCm, weightKg, eyeColor, hairColor, placeOfBirth, medicalCondition, and description.
A read-only plan confirms what the row source looks like before adding an update step:
'use strict';
// Read-only Optic plan: select a subset of columns from the llamaverse view,
// filtered to Suri breed llamas.
// This does NOT require declareUpdate() — it is a read-only operation.
const op = require('/MarkLogic/optic');
op.fromView('llamaverse', 'llamas')
.where(op.eq(op.col('breed'), 'Suri'))
.select(['id', 'name', 'breed', 'heightCm'])
.orderBy(op.desc(op.col('heightCm')))
.limit(3)
.result()
.toArray();
[
{
"llamaverse.llamas.id": "2f84a...",
"llamaverse.llamas.name": "Alejandro",
"llamaverse.llamas.breed": "Suri",
"llamaverse.llamas.heightCm": 200
},
{
"llamaverse.llamas.id": "8c73b...",
"llamaverse.llamas.name": "Beatrix",
"llamaverse.llamas.breed": "Suri",
"llamaverse.llamas.heightCm": 199
},
{
"llamaverse.llamas.id": "1e49d...",
"llamaverse.llamas.name": "Carlos",
"llamaverse.llamas.breed": "Suri",
"llamaverse.llamas.heightCm": 198
}
]
// Column names are qualified with "schema.view.column" by default.
// Use op.as() or op.viewCol() / op.schemaCol() to produce simpler
// column names when the output needs to be more readable.
Column names in the result are qualified as schema.view.column by default. When building update plans you reference these columns by short name using op.col() — MarkLogic resolves the qualification in context.
op.patch() — Patching Documents Through a Pipeline
The patch pattern has three parts: select the rows, define the patch, apply it. joinDoc() joins the underlying document into the row so the patch step has something to modify. op.patchBuilder() defines the node-level operations to perform on each document.
The patchBuilder context path (the argument to op.patchBuilder(path)) is a JSON path or XPath within the document. For flat llamaverse documents, / means the root object — you address properties directly from there.
'use strict';
// Optic Update preview: patch documents identified by the row pipeline.
// Requires declareUpdate() and MarkLogic 11 with Optic Update enabled.
// op.patch() and op.remove() are preview APIs — verify availability for
// your specific MarkLogic installation before deploying.
declareUpdate();
const op = require('/MarkLogic/optic');
// Build a patch definition: replace the medicalCondition property value
// with a new value derived from another column in the plan.
const patch = op.patchBuilder('/')
.replace('medicalCondition', op.col('newCondition'));
op.fromView('llamaverse', 'llamas')
.where(op.eq(op.col('medicalCondition'), 'none'))
.bind(op.as('newCondition', op.val('healthy')))
.joinDoc(op.col('doc'), op.col('uri'))
.patch(op.col('doc'), patch)
.result();
// Expected output (preview API — verify availability on your MarkLogic version):
// Documents where medicalCondition = "none" have the property replaced with "healthy".
// The number of modified documents depends on the data state at execution time.
// If op.patch() is not available server-side in your version of MarkLogic,
// the equivalent operation using the MarkLogic Node.js Client Library's
// Data Services or direct document patching API should be used instead.
patchBuilder operations available in MarkLogic 11 include:
| Method | Effect |
|---|---|
.replace(path, value) | Replace the node at the path with the supplied value |
.replaceInsertChild(parent, node) | Replace a child if it exists; insert if it does not |
.insertChild(parent, node) | Insert a new child node |
.insertBefore(node, newNode) | Insert a sibling before the target node |
.insertAfter(node, newNode) | Insert a sibling after the target node |
.remove(path) | Remove the node at the path from the document |
Note that .remove(path) on a patchBuilder removes a node inside the document — it does not delete the document itself. Whole-document deletion uses the separate plan-level plan.remove().
Using Column Values in Patches
The value argument in a patchBuilder operation can be a literal or an op.col() reference. When it is a column reference, the patch derives its new value from a column resolved earlier in the pipeline — enabling join-driven or expression-derived updates without re-querying outside the plan.
op.remove() — Deleting Documents Through a Pipeline
Whole-document deletion is the other major workflow. The plan resolves URIs and plan.remove() deletes those documents within the current update transaction. This pattern is well-suited to maintenance clean-up, derived-document refreshes, and removing operational data that has aged out.
'use strict';
// Optic Update preview: remove documents identified by the row pipeline.
// Requires declareUpdate() and MarkLogic 11 with Optic Update enabled.
// op.remove() is a preview API — verify availability for your installation.
declareUpdate();
const op = require('/MarkLogic/optic');
// Remove all llamaverse llama documents where heightCm is below 140 cm.
// In a real application, validate the candidate set with a read-only
// plan first, before switching to the removal operation.
op.fromView('llamaverse', 'llamas')
.where(op.lt(op.col('heightCm'), 140))
.select('uri')
.remove('uri')
.result();
// Expected output (preview API — verify availability on your MarkLogic version):
// Documents matching the pipeline criteria are deleted within the current
// update transaction. Rollback occurs automatically if an exception is thrown
// before the transaction commits.
// Safety practice: run the equivalent read-only plan (without .remove()) first
// to confirm the candidate set before executing the deletion.
Always validate the candidate set with a read-only version of the plan before switching to the removal operation. Replacing .remove('uri') with .select('uri').result() gives you the exact list of documents that would be deleted.
Transaction Semantics
Optic Update executes within the current update transaction. In Server-Side JavaScript, this means calling declareUpdate() near the top of the module. The modifying plan participates in the same transaction as any other update work in that module — if the module throws an exception and the transaction aborts, the update plan rolls back with it.
This makes it straightforward to combine an Optic Update with other audit or logging writes in the same module: either everything commits or nothing does.
Bridging from XQuery
Because the preview feature is JavaScript-first, XQuery codebases can access it through xdmp:javascript-eval. This lets you keep the broader orchestration logic in XQuery while delegating the Optic Update implementation to JavaScript:
xquery version "1.0-ml";
(: Call a Server-Side JavaScript Optic Update module from XQuery. :)
(: This pattern is useful for teams whose orchestration code is XQuery but :)
(: whose update plan uses preview JavaScript APIs. :)
declare option xdmp:update "true";
xdmp:javascript-eval('
declareUpdate();
const op = require("/MarkLogic/optic");
op.fromView("llamaverse", "llamas")
.where(op.eq(op.col("medicalCondition"), "none"))
.bind(op.as("newCondition", op.val("healthy")))
.joinDoc(op.col("doc"), op.col("uri"))
.patch(
op.col("doc"),
op.patchBuilder("/").replace("medicalCondition", op.col("newCondition"))
)
.result();
')
The declare option xdmp:update "true" declaration is needed in XQuery to allow update operations. The xdmp:javascript-eval call runs the JavaScript as a side-effect within the same transaction.
Module Isolation Best Practice
Whether in a JavaScript or mixed-language codebase, isolating Optic Update in dedicated modules pays dividends when preview APIs evolve. Put the update plan behind a focused function with a narrow input contract:
'use strict';
// Module isolation pattern: expose Optic Update as narrow, named functions
// rather than scattering update plans across request handlers.
// Callers receive a simple interface; the preview API details stay contained.
declareUpdate();
const op = require('/MarkLogic/optic');
/**
* Replace medicalCondition for a specific llama.
* @param {string} llamaId - The llama UUID to update
* @param {string} newCondition - The new medicalCondition value
*/
function setMedicalCondition(llamaId, newCondition) {
return op.fromView('llamaverse', 'llamas')
.where(op.eq(op.col('id'), llamaId))
.bind(op.as('newCondition', op.val(newCondition)))
.joinDoc(op.col('doc'), op.col('uri'))
.patch(
op.col('doc'),
op.patchBuilder('/').replace('medicalCondition', op.col('newCondition'))
)
.result();
}
module.exports = { setMedicalCondition };
Callers receive a clean interface. If the preview API changes between MarkLogic releases, only the wrapper module needs updating. If you later decide a traditional update loop is more appropriate, your callers are unaffected.
Joining Before Updating
The most powerful Optic Update pattern is resolving update candidates through a join rather than fetching documents directly. The join brings related data into scope, and the patch can reference any column in the resulting rows:
declareUpdate();
const op = require('/MarkLogic/optic');
const llamas = op.fromView('llamaverse', 'llamas');
const records = op.fromView('llamaverse', 'health_records');
llamas
.joinInner(records, op.on(llamas.col('id'), records.col('llamaId')))
.where(op.eq(records.col('checkStatus'), 'clear'))
.joinDoc(op.col('doc'), llamas.col('uri'))
.patch(
op.col('doc'),
op.patchBuilder('/')
.replace('medicalCondition', records.col('checkResult'))
)
.result();
In a relational framing, this is "update where joined row meets condition." In MarkLogic terms, it resolves the document set through indexed TDE columns, carries the check result as a column value, then patches each matched document in one pipeline.
Traditional XQuery Pattern for Comparison
The traditional approach to the same problem is manual and perfectly valid. It remains appropriate for teams that are not ready to adopt preview APIs:
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic"
at "/MarkLogic/optic.xqy";
declare option xdmp:update "true";
let $rows := op:from-view("llamaverse", "llamas")
=> op:where(op:eq(op:col("medicalCondition"), "none"))
=> op:select((op:col("uri")))
=> op:result()
for $row in $rows
let $uri := map:get($row, "uri")
let $doc := fn:doc($uri)
return
if (fn:exists($doc))
then xdmp:node-replace($doc/medicalCondition, text { "healthy" })
else ()
This resolves rows and updates each document individually. Optic Update removes the need for the manual loop — but the traditional pattern is more portable across MarkLogic versions.
Performance Considerations
Optic Update does not create performance automatically. The performance profile depends on the row source, the join shape, and the number of documents modified. A few practical rules:
| Pattern | Why it matters |
|---|---|
Start from op.fromView() | TDE-backed views use indexed resolution; candidate set reduction is efficient |
| Filter aggressively before patching | Every additional row that reaches the patch step costs a document write |
| Patch only the affected path | patchBuilder.replace(path) is cheaper than rewriting the full document |
| Validate with a read-only plan first | Confirm candidate count before executing an update |
| Scope removals with cheap constraints | Collection queries and range filters reduce the deletion footprint |
Limitations to Keep in Mind
| Limitation | Why it matters |
|---|---|
| Preview feature in MarkLogic 11 | APIs may evolve; plan.patch() and plan.remove() may not be available in all builds |
| JavaScript-first | XQuery teams need a wrapper module or xdmp:javascript-eval to access the API |
| No XQuery equivalent for plan-level update | The op:from-view() XQuery API does not have equivalent patch/remove plan methods |
| Standard update costs still apply | Large-scale patches, locking, and transaction size affect throughput regardless of the API |
Requires TDE (for fromView) | Documents without a matching TDE view cannot be selected via op.fromView() |
Quick Reference
| Operation | Optic Update pattern | Notes |
|---|---|---|
| Patch documents from TDE view | fromView().where().bind().joinDoc().patch() | Requires preview plan.patch() |
| Delete documents from URI pipeline | fromLexicons().where().select('uri').remove('uri') | Requires preview plan.remove() |
| Join before update | joinInner() before .patch() | Carries joined columns into the patch definition |
| Bridge from XQuery | xdmp:javascript-eval(...) | XQuery orchestration with JS update implementation |
| Traditional equivalent | op:result() then xdmp:node-replace() loop | Portable across all MarkLogic 11 builds |
Need Some Help?
Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!
- The API Landscape
- The Core Design: Rows Flow Into Mutation
- op.fromView() as the Starting Point
- op.patch() — Patching Documents Through a Pipeline
- Using Column Values in Patches
- op.remove() — Deleting Documents Through a Pipeline
- Transaction Semantics
- Bridging from XQuery
- Module Isolation Best Practice
- Joining Before Updating
- Traditional XQuery Pattern for Comparison
- Performance Considerations
- Limitations to Keep in Mind
- Quick Reference