Migrating Server-Side JavaScript to MarkLogic 12

What Breaks, What Improves, and How to Upgrade Without Guesswork

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2
databaseMinimum MarkLogic Version: 12

This upgrade can still surprise experienced teams. The first pass looks easy, then real module behaviour shows up: top-level side effects, legacy fallback habits, old helper shims, and numeric assumptions no one documented because they never caused pain before.

MarkLogic 12 is stricter, and that is good engineering pressure. Weak patterns fail earlier, code review quality goes up, and module boundaries become clearer.

This guide is written for teams upgrading from pre-MarkLogic 12 JavaScript to MarkLogic 12. The order is deliberate:

  1. First, get your existing code moving again.
  2. Then, use the new language features that actually make modules better.
  3. Finally, go deeper into module semantics and engine behaviour so you can modernise with intent instead of guessing.

Sample Data

The examples in this article assume the llamaverse (v2.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.

How to Use This Guide

  1. If you are in the middle of an upgrade, start with the backward-compatibility section.
  2. Run the compatibility samples against your real modules before doing wider refactors.
  3. Move to the newer feature section only after your existing modules are stable again.
  4. Use the deep-dive section when you want to set a clear team standard for MarkLogic 12 JavaScript.

Upgrade Facts and Fixes

This section leads with the changes that affect real code first. The goal here is simple: help you see what MarkLogic 12 changes, where older code will need to move, and what the replacement patterns look like.

A Note on use strict

In MarkLogic 12, strict mode is automatic for module-style JavaScript. That includes .mjs modules and any JavaScript that is treated as a module because it uses import or export (including Query Console or xdmp.eval code in module form, and .sjs called from .mjs).

Older script-style JavaScript is only strict when you declare it. That is one reason some legacy patterns fail earlier after upgrade.

First Breaking Change: Main Modules Must Explicitly Export Their Result

The MarkLogic 12 incompatibility notes are explicit about this one: JavaScript main modules that are executed as main programs now need export default if they are meant to return a value.

If you have older code that simply ends with a variable or expression, that is the first pattern to check.

const result = {
    llamaId: external.llamaId,
    movementUri: external.movementUri,
    status: external.status
};

result;
const result = {
    llamaId: external.llamaId,
    movementUri: external.movementUri,
    status: external.status
};

export default result;

This applies to JavaScript modules invoked as main programs, such as HTTP endpoints, xdmp.eval code that uses module features, .mjs files, Query Console code that becomes a module because it uses import or export, and .sjs files called from an .mjs file.

If you need a compatibility bridge during migration, the MarkLogic 12 incompatibility note shows that you can temporarily use both patterns:

const result = {
    llamaId: external.llamaId,
    movementUri: external.movementUri,
    status: external.status
};

result;
export default result;

Major points to consider

TopicMarkLogic 12 guide pointWhy it matters in an upgrade
Engine levelMarkLogic 12 embeds V8 12.4 and uses ECMAScript 2024This is a real runtime shift, not a cosmetic version bump
Generator supportGenerators use function*, but MarkLogic only supports Generator.prototype.next()Do not assume the full generator control surface is available
Served module typesApp servers serve .sjs and .mjs, not .js directlyModule naming and serving rules matter during refactors
Scripting modelxdmp.invoke and xdmp.invokeFunction remain the documented tools for orchestrating separate workTransaction boundaries are still a MarkLogic concern, not a generic JavaScript concern
Thread modelEach app server thread has its own V8 isolateFunction objects and similar JS state cannot be shared across threads
Exception semanticsJavaScript try/catch semantics are not the same as XQuery rollback semanticsUpdate behaviour needs to be tested, not assumed
XQuery interopJavaScript and XQuery can call into each other directlyMixed-language implementations remain fully supported; note that cross-language hops still have a cost, so prefer fewer, coarser transitions per request instead of ping-ponging between JavaScript and XQuery many times

Backward Compatibility

This is usually where teams get unstuck fastest.

If you are upgrading from an older MarkLogic JavaScript engine, do not start by scattering modern syntax across the codebase. Start by finding the parts of the existing code that were relying on the older engine being forgiving.

Compatibility Risk Matrix

Legacy patternWhy it hurts on MarkLogic 12First repair
Top-level side effects during module loadFailures happen before request logic reaches its guardsMove work into explicit entry functions
Implicit globalsStricter execution catches undeclared identifiersReplace with explicit const or let
value || default for meaningful falsey values0, false, and empty strings can be rewritten accidentallySwitch to ?? where nullish semantics are intended
Old polyfills shadowing newer built-insNative behaviour is masked or changedRemove or isolate compatibility shims
Number and BigInt mixingRuntime type errors appear under real load pathsNormalise numeric boundaries explicitly
JSON serialisation of BigIntJSON.stringify fails at runtimeConvert BigInt to string at transport boundaries

Taking the table above into consideration, here is a concrete example that explores several of those specific changes. It removes implicit global mutation, so load-time side effects are no longer hidden in module initialisation. It also replaces value || default with value ?? default, which keeps valid values like 0 and false intact. Finally, it moves execution into an explicit main() path, so it is much clearer what runs, when it runs, and where state is initialised.

Before/After: Legacy Main Module to Safer Main Module

"use strict";

// Fragile: side effects happen as soon as the main module is loaded.
llamaverseBootstrapState = llamaverseBootstrapState || { runs: 0 };
llamaverseBootstrapState.runs += 1;

function handleRequest(input) {
  const maxMovementLagMinutes = input.maxMovementLagMinutes || 15;
  return {
    runs: llamaverseBootstrapState.runs,
    llamaId: input.llamaId,
    maxMovementLagMinutes
  };
}

handleRequest({
  llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  maxMovementLagMinutes: 0
});
"use strict";

function initialiseState() {
  const state = xdmp.getServerField("llamaverse-upgrade-state") || { runs: 0 };
  state.runs += 1;
  xdmp.setServerField("llamaverse-upgrade-state", state);
  return state;
}

function normaliseMovementLag(value) {
  return value ?? 15;
}

function main(input) {
  const state = initialiseState();
  return {
    runs: state.runs,
    llamaId: input.llamaId,
    maxMovementLagMinutes: normaliseMovementLag(input.maxMovementLagMinutes)
  };
}

main({
  llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  maxMovementLagMinutes: 0
});
ChangeBeforeAfterUpgrade value
State initialisationImplicit global mutationExplicit helper functionEasier to test and safer under stricter execution
Default handlingvalue || 100value ?? 100Preserves valid falsey values
Entry pointWork happens during loadWork happens in main()Clearer control flow and fewer startup surprises

Compatibility Sample: Transaction-Safe Main Module

This is the practical pattern in one place: validate input, declare update intent, then run one clear write path.

"use strict";
declareUpdate();

function validateInput(input) {
  if (!input || !input.llamaId || !input.timestamp || !input.location) {
    throw new Error("llamaId, timestamp, and location are required");
  }

  if (typeof input.location.lat !== "number" || typeof input.location.lon !== "number") {
    throw new Error("location.lat and location.lon must be numbers");
  }
}

function upsertLlamaLocationRecord(input) {
  const uri = `/cleverllamas/llamaverse/content/llama-movement/${input.llamaId}-${input.timestamp}.json`;
  const doc = {
    type: "llama-location-record",
    llamaId: input.llamaId,
    timestamp: input.timestamp,
    location: {
      lat: input.location.lat,
      lon: input.location.lon
    },
    source: input.source ?? "gps-collar"
  };

  xdmp.documentInsert(uri, doc, {
    collections: ["llamaverse", "llama-movement", "marklogic-12-javascript"]
  });

  return {
    uri,
    llamaId: input.llamaId,
    source: doc.source
  };
}

function main(input) {
  validateInput(input);
  return upsertLlamaLocationRecord(input);
}

main({
  llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  timestamp: "2026-07-08T09-30-00Z",
  location: {
    lat: 51.5074,
    lon: -0.1278
  },
  source: "manual-check"
});
{
  "uri": "/cleverllamas/llamaverse/content/llama-movement/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5-2026-07-08T09-30-00Z.json",
  "llamaId": "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  "source": "manual-check"
}

This is the pattern to prefer when you are taking an old update-heavy entry module and making it behave predictably on MarkLogic 12.

Before/After: Function Module Style

Use this side-by-side comparison to see how the same helper looks in CommonJS-style and ES module style.

'use strict';

function llamaLabel(llama) {
    return llama.name + " from " + llama.placeOfBirth;
}

module.exports.llamaLabel = llamaLabel;
export function llamaLabel(llama) {
    return `${llama.name} from ${llama.placeOfBirth}`;
}

If you convert a reusable module to ES module style, be clear about what you export. Anything you do not export is private to that module.

Before/After: Served Module Extensions

This is here to prevent a subtle deployment mistake: extension choice controls whether a module can be served directly by an app server endpoint in MarkLogic 12.

// /reports/llama-report.js
// Requesting this path directly as an endpoint fails because .js is not a served
// server-side JavaScript extension on MarkLogic app servers.
// Example request: /reports/llama-report.js

const result = {
    report: "llama movement health",
    status: "ok",
    servedByExtension: false
};

export default result;
// /reports/llama-report.sjs
// This extension is served directly by MarkLogic app servers.
// Example request: /reports/llama-report.sjs

const result = {
    report: "llama movement health",
    status: "ok",
    servedByExtension: true
};

export default result;

The important point is that the JavaScript body can look almost identical, but deployment behaviour changes with the extension:

  1. .js is fine as source code in your repository, but not as a directly served app server endpoint.
  2. .sjs and .mjs are the directly served server-side JavaScript endpoint extensions.
  3. During refactors, keep endpoint paths and file extensions aligned, or requests will fail before your module logic runs.

Before/After: Update Helper Versus Update Entry Module

This comparison separates pure reusable logic from the module that performs an explicit write.

export function makeLlamaSummary(llama) {
    return {
        id: llama.id,
        name: llama.name,
        medicalCondition: llama.medicalCondition?.name ?? "none"
    };
}
import { makeLlamaSummary } from "/lib/llamas.mjs";

declareUpdate();

const llama = {
    id: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
    name: "Aaron",
    medicalCondition: { name: "none" }
};

xdmp.documentInsert(
    "/cleverllamas/llamaverse/content/reports/aaron-summary.json",
    makeLlamaSummary(llama)
);

The important distinction is that export controls visibility, while declareUpdate() controls update intent. They are separate rules and should stay separate in your code.

Compatibility Sample: BigInt Boundary Trap

This sample shows both sides of BigInt handling: safe arithmetic in-process and explicit conversion at transport boundaries.

const movementSequence = 9_223_372_036_854_775_807n;

const payload = {
  llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  movementSequence,
  status: "healthy"
};

let jsonFailure;
try {
  JSON.stringify(payload);
} catch (err) {
  jsonFailure = err.message;
}

const arithmeticOk = movementSequence + 1n;

({
  arithmeticOk: `${arithmeticOk.toString()}n`,
  jsonFailure,
  safeTransport: {
    llamaId: payload.llamaId,
    movementSequence: movementSequence.toString(),
    status: payload.status
  }
});
{
  "arithmeticOk": "9223372036854775808n",
  "jsonFailure": "Do not know how to serialize a BigInt",
  "safeTransport": {
    "llamaId": "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
    "movementSequence": "9223372036854775807",
    "status": "healthy"
  }
}

Practical rule:

  1. Use BigInt when precision matters.
  2. Convert to string at JSON and API boundaries.
  3. Convert back deliberately on ingestion.

When validating through /v1/eval, make sure any BigInt in the returned payload is converted to a string before return, or response serialization will fail.

Working Sample: BigInt String Serialisation

This companion sample shows the same scenario in a form that serialises cleanly.

const movementRecord = {
    llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
    movementSequence: 9223372036854775807n,
    status: "healthy"
};

const transportPayload = {
    llamaId: movementRecord.llamaId,
    movementSequence: movementRecord.movementSequence.toString(),
    status: movementRecord.status
};

const json = JSON.stringify(transportPayload);
const roundTrip = JSON.parse(json);

({
    json,
    movementSequenceTypeAfterParse: typeof roundTrip.movementSequence,
    movementSequenceValueAfterParse: roundTrip.movementSequence
});
{
    "json": "{\"llamaId\":\"0c8bdb0d-ac62-49b7-ac74-94dbba46efa5\",\"movementSequence\":\"9223372036854775807\",\"status\":\"healthy\"}",
    "movementSequenceTypeAfterParse": "string",
    "movementSequenceValueAfterParse": "9223372036854775807"
}

Bonus What-Not-To-Do: cts.andQuery Bracket Trap

This one is important enough to include here even though it is not a new ML12 behavior.

If you write cts.andQuery with square brackets instead of parentheses around the array argument, the expression is still valid JavaScript but not the query you intended.

If this resolves to an empty andQuery, it filters nothing and can return the entire database in scope.

cts.plan(cts.andQuery[cts.falseQuery()]);
cts.plan(cts.andQuery([cts.falseQuery()]));

Why this is dangerous:

  1. cts.andQuery[cts.falseQuery()] is treated as JavaScript property indexing on a function.
  2. That can resolve in ways that leave you with an empty andQuery.
  3. An empty andQuery can broaden scope and return the whole database visible to that user.

The plan output below is the kind of evidence you can see when this goes wrong:

[
    {
        "exprTrace": "xdmp:invoke(function bound (), Element(\"<options xmlns='xdmp:eval'><database>14411921375042116334</database>...</options>\"))"
    },
    "Analyzing path for search: fn.doc()",
    "Step 1 is searchable: fn.doc()",
    "Path is fully searchable.",
    "Gathering constraints.",
    "Executing search.",
    {
        "ordering": []
    },
    {
        "finalPlan": {
            "query": {
                "andQuery": {}
            }
        }
    },
    "Selected 6157 fragments to filter",
    {
        "result": {
            "estimate": 6157
        }
    }
]

This is exactly the kind of production-grade lesson that rarely appears in official guides, but it is worth baking into review checklists.

Upgrade Triage Checklist

  1. Load every JavaScript entry module in a safe environment and fix hard failures first.
  2. Identify top-level writes, side effects, and implicit globals.
  3. Search for JSON.stringify near BigInt values or identifier-like numerics.
  4. Remove polyfills that now compete with native helpers.
  5. Re-run high-traffic paths before any stylistic refactor.

Extra Check for Older Codebases

If your codebase carries patterns all the way forward from much older MarkLogic releases, one older JavaScript incompatibility is still worth calling out: the old ValueIterator interface was replaced by Sequence.

That usually means changing code like this:

const values = someFunctionReturningSequence();
const first = values.next().value;
const count = values.count();
const values = someFunctionReturningSequence();
const first = fn.head(values);
const count = fn.count(values);

for (const value of values) {
    xdmp.log(value);
}

If you see .next(), .count(), or .clone() on old iterator-shaped values, check whether that code predates the Sequence change.

New Features That Genuinely Improve MarkLogic Code

Once the hard breaks are fixed, this is where the fun begins.

The key question is not, "Which shiny features exist?" It is, "Which features remove risk and make daily module work easier to reason about?" Those are the features worth adopting first.

Before/After: Nested Llamaverse Access

This before/after shows the same field access logic with and without modern null-safe syntax.

const medicalConditionName = llama && llama.medicalCondition && llama.medicalCondition.name
    ? llama.medicalCondition.name
    : "none";

const secretPowerName = llama && llama.secretPower && llama.secretPower.name
    ? llama.secretPower.name
    : "none";
const medicalConditionName = llama?.medicalCondition?.name ?? "none";
const secretPowerName = llama?.secretPower?.name ?? "none";

Example: Optional Chaining on Llamaverse Data

Llamaverse documents are rich and irregular. Not every llama has a medicalCondition. Not every record includes a secretPower. Before optional chaining, modules that needed to read deeply nested fields safely were cluttered with existence checks: llama && llama.medicalCondition && llama.medicalCondition.name. Those chains were repetitive, fragile, and easy to get wrong when the document shape evolved.

The ?. operator collapses that into a single readable expression. If any step in the chain is null or undefined, the whole expression short-circuits to undefined rather than throwing. Combined with ??, which substitutes a default only when the value is null or undefined (not when it is 0 or false or an empty string), this pattern eliminates a whole class of defensive boilerplate without hiding real values.

const llama = {
  id: "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
  name: "Teresa",
  medicalCondition: {
    name: "hypertension",
    id: "298c2e45-9356-4a87-9a34-34fefe5e9c83"
  },
  secretPower: {
    name: "Camel Whisperer",
    id: "01b3e8c0-3d5f-4f83-b672-97dfbe5ae40b"
  },
  relatedTo: {
    id: "2308d164-b3e2-4244-9e0c-fdd556a2b875",
    relationship: "sister"
  }
};

const medicalConditionName = llama?.medicalCondition?.name ?? "none";
const secretPowerName = llama?.secretPower?.name ?? "none";
const relatedRelationship = llama?.relatedTo?.relationship ?? "unrelated";

({
  llamaName: llama?.name ?? "unknown",
  medicalConditionName,
  secretPowerName,
  relatedRelationship
});
{
  "llamaName": "Teresa",
  "medicalConditionName": "hypertension",
  "secretPowerName": "Camel Whisperer",
  "relatedRelationship": "sister"
}

High-Value Additions

FeatureWhy it matters in MarkLogic modulesImmediate payoff
Optional chaining (?.)Safe traversal of document-shaped dataLess defensive boilerplate
Nullish coalescing (??)Correct defaults for missing values onlyFewer falsey-value bugs
Logical assignment (??=, ||=, &&=)Cleaner initialisation logicSmaller setup code
Object.hasOwn()More robust payload filteringFewer prototype-chain surprises
String.prototype.matchAll()Better extraction from structured textCleaner parsing code
RegExp match indices (/d)Precise offsets when analysing contentLess manual index bookkeeping
Array.prototype.at()Clear end-relative indexingBetter readability in report-style modules
Private class fieldsSafer module-local stateLess closure-heavy scaffolding

Example: Private Class Fields for Module-Local State

Before private class fields landed in the language, keeping state out of reach from module consumers required closures or naming conventions like _events. Closures worked but made classes harder to read. Conventions only worked if everyone on the team respected them — nothing in the runtime enforced them, and obj._warnings was perfectly accessible from outside.

Private class fields, declared with a # prefix, are enforced by the engine. Code outside the class cannot read or write them — not even with bracket notation. Accessing audit.#events from outside the class is a SyntaxError, not just a bad practice. This is the kind of guarantee that makes module-local state genuinely safe to rely on, rather than simply conventionally private.

For MarkLogic modules that track per-request state such as audit trails or processing records, this gives you a clean, compact class design.

class LlamaverseMovementAudit {
  #events = [];

  addEvent(event) {
    this.#events.push(event);
  }

  summary() {
    return {
      eventCount: this.#events.length,
      latestEvent: this.#events.at(-1) ?? null
    };
  }
}

const audit = new LlamaverseMovementAudit();
audit.addEvent({
  llamaId: "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  movementUri: "/cleverllamas/llamaverse/content/llama-movement/2026/07/08/aaron-fix-001.json",
  note: "legacy payload missing source"
});
audit.addEvent({
  llamaId: "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
  movementUri: "/cleverllamas/llamaverse/content/llama-movement/2026/07/08/teresa-fix-014.json",
  note: "BigInt movement sequence normalised for JSON boundary"
});

audit.summary();
{
  "eventCount": 2,
  "latestEvent": {
    "llamaId": "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
    "movementUri": "/cleverllamas/llamaverse/content/llama-movement/2026/07/08/teresa-fix-014.json",
    "note": "BigInt movement sequence normalised for JSON boundary"
  }
}

Example: Object.hasOwn() for Safer Payload Filtering

MarkLogic endpoints often receive inbound payloads and need to decide which keys to accept. The traditional approach was obj.hasOwnProperty(key), which works in most cases but has a subtle failure mode: if the object was created with Object.create(null), or if someone has overridden hasOwnProperty on the object prototype, the check silently behaves incorrectly.

In production code that processes external payloads, those edge cases are not academic. Object.hasOwn() is the safer replacement — it calls the underlying check directly without going through the prototype chain, and it works correctly even on null-prototype objects. The example here uses Object.create() with an inherited key deliberately, so you can see that the inherited field is correctly excluded while own fields pass through.

"use strict";

const payload = Object.create({ inherited: "ignore-me" });
payload.llamaId = "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5";
payload.movementUri = "/cleverllamas/llamaverse/content/llama-movement/2026/07/08/aaron-fix-001.json";
payload.recordedAt = "2026-07-08T09:30:00Z";

const allowed = ["llamaId", "movementUri", "recordedAt", "source"];
const extracted = {};

for (const key of allowed) {
  if (Object.hasOwn(payload, key)) {
    extracted[key] = payload[key];
  }
}

({
  extracted,
  inheritedSeenAsOwn: Object.hasOwn(payload, "inherited")
});
{
  "extracted": {
    "llamaId": "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
    "movementUri": "/cleverllamas/llamaverse/content/llama-movement/2026/07/08/aaron-fix-001.json",
    "recordedAt": "2026-07-08T09:30:00Z"
  },
  "inheritedSeenAsOwn": false
}

Example: matchAll() for Structured Token Extraction

Before matchAll(), extracting multiple matches from a string required a manual while loop with exec(), resetting and advancing the regex index by hand. It was easy to forget g on the regex, easy to produce an infinite loop if the match advanced zero characters, and the resulting code was always more bookkeeping than logic.

matchAll() returns an iterator over all matches in one call. Each match gives you the full match object including capture groups, so you can extract structured data from a text feed without managing loop state manually. For MarkLogic use cases like parsing movement logs, diagnostic feeds, or tagged content, this makes extraction code substantially shorter and easier to verify.

"use strict";

const text = [
  "uri=/cleverllamas/llamaverse/content/llama-movement/2026/07/08/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5-fix-001.json recordedAt=2026-07-08T09:30:00Z",
  "uri=/cleverllamas/llamaverse/content/llama-movement/2026/07/08/81b4631f-0ddb-45dd-aa6f-4cf3ac09b259-fix-014.json recordedAt=2026-07-08T11:10:00Z"
].join("; ");

const pattern = /llama-movement\/\d{4}\/\d{2}\/\d{2}\/([0-9a-f-]{36})-fix-\d+\.json\s+recordedAt=([^;]+)/g;

const moves = [];
for (const match of text.matchAll(pattern)) {
  moves.push({
    llamaId: match[1],
    recordedAt: match[2].trim()
  });
}

moves;
[
  {
    "llamaId": "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
    "recordedAt": "2026-07-08T09:30:00Z"
  },
  {
    "llamaId": "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
    "recordedAt": "2026-07-08T11:10:00Z"
  }
]

Example: RegExp Match Indices for Precise Offsets

When you match a pattern in a string and then need to know exactly where in the original text the match appeared, the old approach was to walk the string manually: find the match, then use indexOf or count characters to locate it. That works for simple cases but becomes error-prone quickly when matches are variable-length or when you need offsets for capture groups specifically.

The /d flag on a regular expression adds a .indices property to each match result. This gives you the [start, end] position of the whole match and each capture group directly, without any extra string walking. For modules that process URIs, log lines, or structured content fields and need to pass offsets downstream (for example, into highlight ranges or content extraction jobs), this is a clean native solution to a problem that previously needed a small helper function to solve reliably.

const text = "uri=/cleverllamas/llamaverse/raw/wild-llamas/llamas/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5.json status=healthy";
const pattern = /(llamas\/[0-9a-f-]{36}\.json)/d;
const match = pattern.exec(text);

if (!match) {
  ({ found: false });
} else {
  ({
    found: true,
    token: match[1],
    start: match.indices[1][0],
    end: match.indices[1][1]
  });
}
{
  "found": true,
  "token": "llamas/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5.json",
  "start": 45,
  "end": 93
}

Example: Numeric Boundary Normalisation

The trap section earlier showed what breaks when a BigInt hits a JSON boundary. This sample is the production-grade answer to that problem: a consistent normalisation strategy applied across a whole module rather than a one-off fix.

The two helper functions — toBigInt() and toTransport() — handle all the common input forms cleanly. Any numeric value that arrives as a string, a Number, or already a BigInt gets normalised to BigInt for internal computation. Any value going back out to JSON or an API boundary gets converted to a string explicitly before it leaves. Applied consistently, this pattern means BigInt precision boundaries become a module-level rule rather than something each developer has to remember at each serialisation point.

"use strict";

function toBigInt(value) {
  if (typeof value === "bigint") {
    return value;
  }
  if (typeof value === "number") {
    return BigInt(value);
  }
  if (typeof value === "string") {
    return BigInt(value);
  }
  throw new TypeError("Unsupported numeric type");
}

function toTransport(value) {
  return value.toString();
}

const movementDoc = {
  llamaId: "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
  movementSequence: "9223372036854775807"
};

const nextSequence = toBigInt(movementDoc.movementSequence) + 25n;

({
  llamaId: movementDoc.llamaId,
  totalAsBigInt: `${nextSequence.toString()}n`,
  totalForJson: toTransport(nextSequence)
});
{
  "llamaId": "81b4631f-0ddb-45dd-aa6f-4cf3ac09b259",
  "totalAsBigInt": "9223372036854775832n",
  "totalForJson": "9223372036854775832"
}

Deep Dive: What Actually Changed and How to Use It Well

This section is for teams who want more than a passable upgrade. The goal is a sharper mental model for writing MarkLogic 12 JavaScript that behaves predictably under pressure.

Engine Delta Matrix

DimensionOlder engine tendencyMarkLogic 12 tendencyPractical result
Parser behaviourSome weak patterns slipped throughEarlier and stricter failureBad modules fail sooner and more usefully
Built-in helpersTeams carried more scaffoldingMore native helpers are availableOld shims become liabilities
Module styleScript-heavy and side-effect-prone code survivedExplicit module boundaries work betterBetter determinism
Regex and extractionMore manual looping and index managementStronger extraction helpersLess brittle parsing
Numeric strategyMostly Number-onlyBigInt joins the toolboxBetter precision with clearer boundaries

Runtime Rules the ML12 Guide Makes Explicit

These are easy to miss if you only focus on syntax, but they shape how modules actually behave.

Runtime ruleWhat the guide saysPractical implication
Served entry modules.js is not directly served from the app server; .sjs and .mjs areRefactors must preserve the correct served extension
Generator behaviourOnly Generator.prototype.next() is supportedAvoid assuming return() or throw() support in generator-based code
Cross-thread behaviourEach app server thread runs its own V8 isolateDo not cache function objects for use across spawned or separate-thread work
Scripting orchestrationxdmp.invoke can invoke modules and xdmp.invokeFunction can invoke JavaScript functionsKeep transaction splitting and orchestration explicit
Exception handlingCompleted JavaScript statements in a try block are not rolled back just because a later statement throws and is caughtUpdate tests must verify partial-success behaviour explicitly
Mixed-language executionJavaScript and XQuery can invoke and eval each otherUpgrade reviews should include interop boundaries, not only pure JS files

MarkLogic 11 Versus 12: Practical Differences

TopicOlder baselineMarkLogic 12 directionUpgrade consequence
ECMAScript surfaceNarrower set of modern featuresMuch broader modern supportRemove compatibility scaffolding carefully
Main module toleranceMore legacy habits slipped throughStricter failures during load and executionCatch problems in test, not production
Function module designOlder require() patterns dominatedES module design is now worth using intentionallyEstablish one standard per module type
Data traversalMore boilerplate around missing fieldsCleaner native access patternsBetter readability for document logic
Numeric precisionNumber-only habits persistedBigInt is viable but must be boundedWrite boundary rules down

Upgrade Runbook

  1. Stabilise: build an inventory of entry modules and fix parse or load failures first.
  2. Harden: refactor top-level side effects, implicit globals, and numeric boundaries.
  3. Modernise: adopt optional chaining, nullish defaults, ownership guards, and clearer module APIs.
  4. Standardise: decide where CommonJS-style modules remain acceptable and where ES modules become the team default.
  5. Verify: rerun smoke tests, high-traffic paths, and transport-boundary checks.

Quick Comparison

AreaPre-MarkLogic 12 tendencyMarkLogic 12 realityWhat to do first
Legacy main modulesTop-level work and loose state were commonStricter execution exposes weak patterns earlierRefactor entry points before feature work
Reusable function modulesOld require() and exports patterns often mixed with script-style assumptionsES modules use explicit export, and unexported declarations stay privateDecide whether a module stays CommonJS-style or becomes ES module style
Data traversalRepetitive defensive access logicOptional chaining and nullish defaults clean this up sharplyReplace fragile access paths in high-traffic modules
Numeric handlingNumber-only habits survived longer than they should haveBigInt is useful, but JSON boundaries need disciplineDefine a serialisation rule early
Polyfills and helpersOld compatibility scaffolding lingeredNative helpers now cover much more groundAudit and remove helper clutter deliberately

Appendix: Upgrade Audit Patterns (Copy/Paste Ready)

This appendix gives fast search patterns for common migration risks in Server-Side JavaScript. Use it as a first-pass audit before deeper manual review.

Risk Pattern Table

Risk categorySearch patternWhy it mattersAction
Implicit globals^[[:space:]]*[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=Often signals undeclared assignment in legacy modulesAdd explicit const/let and scope review
Top-level side effectsxdmp\.documentInsert|xdmp\.nodeReplace|xdmp\.nodeDeleteUpdate operations at load time can cause non-deterministic behaviourMove updates into explicit handlers
Legacy ownership checks\.hasOwnProperty\(Can be fragile with unusual prototypesReplace with Object.hasOwn()
BigInt JSON boundaryJSON\.stringify\( plus n literals or BigInt\( nearbyBigInt cannot be serialised directlyConvert BigInt to string before transport
Polyfill collisions`Object.assign\s*=Array.prototype.at\s*=`Local polyfills can shadow modern native behaviour
Ordering assumptionsUnchecked completion order in testsTiming assumptions can break across engine versionsMake ordering explicit in tests

Grep Commands for Module Audits

Run these command groups as a fast first pass to surface common upgrade risks before deeper manual review.

# Finds lines that look like bare assignments without a var/let/const/function declaration.
# These are candidates for implicit globals — variables that become attached to the global scope
# rather than being scoped to the module. Under strict mode they throw; under looser engines
# they silently pollute shared state. Review every result to confirm it is a declared variable.
grep -RInE '^[[:space:]]*[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=' src/ | grep -E '\.sjs|\.js'

# Finds all uses of the legacy .hasOwnProperty() pattern.
# This is safe in most cases but fails silently on null-prototype objects and can be overridden.
# Each result is a candidate to replace with Object.hasOwn().
grep -RIn '\.hasOwnProperty\(' src/ | grep -E '\.sjs|\.js'
# Finds xdmp write calls anywhere in the module tree.
# Any result in a file that is loaded as a library module (rather than a main entry module)
# is a risk: writes that happen at load time are non-deterministic and hard to test.
grep -RInE 'xdmp\.documentInsert|xdmp\.nodeReplace|xdmp\.nodeDelete' src/ | grep -E '\.sjs|\.js'

# Finds all JSON.stringify calls. Cross-reference with the BigInt search below —
# if a file contains both, it may be passing a BigInt into JSON.stringify, which will throw at runtime.
grep -RIn 'JSON\.stringify\(' src/ | grep -E '\.sjs|\.js'

# Finds BigInt literals (e.g. 9007199254740993n) and BigInt() constructor calls.
# Any file that appears in both this result and the JSON.stringify result above needs
# an explicit .toString() conversion before serialisation.
grep -RInE 'BigInt\(|[0-9]+n\b' src/ | grep -E '\.sjs|\.js'
# Finds places where built-in methods are being reassigned.
# Old compatibility shims sometimes do this to backfill behaviour that is now native in MarkLogic 12.
# After upgrade, these assignments silently override the real native implementation,
# which can produce subtly wrong behaviour. Each result should be removed or isolated.
grep -RInE 'Object\.assign\s*=|Array\.prototype\.at\s*=' src/ | grep -E '\.sjs|\.js'

Final Guidance

MarkLogic 12 gives you tangible engineering upside: broader language support, cleaner module structure, and less maintenance baggage around everyday data traversal.

The trade-off is healthy discipline. Legacy assumptions become visible, and that visibility is what lets you harden a production codebase instead of carrying hidden risk forward.

Treat the upgrade as a structured modernisation pass, not just a version bump. Fix compatibility first, document numeric and serialisation boundaries, and then standardise module style. Done well, you get better correctness and better readability at the same time.

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!
  • Before/After: Update Helper Versus Update Entry Module