Production patterns for NetSuite SuiteScript 2.1: a Map/Reduce template whose summarize stage actually reports failures, a governance guard for bulk operations, and field notes on the async entity deduplication engine.
These are the patterns that survive contact with real data volumes. Each one exists because its absence caused a specific, expensive failure.
Because errors thrown in map() and reduce() do not fail the script.
NetSuite catches them, records them against the key, and hands them to
summarize(). If you do not drain mapSummary.errors and
reduceSummary.errors, those errors disappear. The deployment screen shows a
completed run. The logs show a clean finish. And some fraction of your records
were never processed.
This is the most common Map/Reduce defect in production, and it is invisible by construction — a script that processes 80% of its input looks exactly like a script that processes 100%.
There are three separate error channels, and all three must be drained:
const summarize = (summary) => {
const failures = [];
// 1. getInputData failed outright
if (summary.inputSummary.error) {
log.error({ title: 'getInputData failed', details: summary.inputSummary.error });
failures.push({ stage: 'input', error: summary.inputSummary.error });
}
// 2. per-key failures in map
summary.mapSummary.errors.each((key, error) => {
log.error({ title: `map error on key ${key}`, details: error });
failures.push({ stage: 'map', key, error });
return true;
});
// 3. per-key failures in reduce
summary.reduceSummary.errors.each((key, error) => {
log.error({ title: `reduce error on key ${key}`, details: error });
failures.push({ stage: 'reduce', key, error });
return true;
});
if (failures.length > 0) {
throw new Error(`Completed with ${failures.length} failed record(s).`);
}
};Two details that bite people:
.errors is an iterator, not an array. It exposes .each(). Calling
.forEach() or .map() on it is a silent no-op, which reintroduces exactly
the bug you were trying to fix.
The callback must return true to continue. Returning nothing — or
anything falsy — stops iteration after the first error, so you see one failure
and assume it was the only one.
Full template: src/map-reduce-template.js
Check the governance budget before each operation, and keep a reserve.
NetSuite terminates a script that exceeds its limit. It does not roll back the work already done. A bulk update that dies at record 4,000 of 10,000 leaves 4,000 records changed, 6,000 untouched, and no record of where the boundary fell — so a naive re-run double-applies the first 4,000.
const guard = require('./governance-guard');
// Need budget for the save AND enough left over to exit cleanly
guard.assertAffordable(guard.COST.RECORD_SAVE, 1, 'PO update');The reserve is the part people leave out. You need units in hand to log the outcome, write a checkpoint, and reschedule. Spending down to zero means dying before you can record where you stopped.
Costs that dominate real scripts:
| Operation | Units |
|---|---|
record.load (entity) |
5 |
record.save (entity) |
10 |
record.submitFields |
10 |
record.load (transaction) |
10 |
record.save (transaction) |
20 |
query.runSuiteQL |
10 |
https.request |
10 |
Budgets by script type: user event 1,000 · scheduled 10,000 · Map/Reduce
map 1,000 per key, reduce 5,000 per key.
Full module: src/governance-guard.js
Because in a bulk run, the async deduplication engine does not reliably
honour masterRecordId. Groups come back merged into a master that is not
the id you supplied. The specific failure observed in production: a Lead
record surviving instead of the Customer that was explicitly nominated.
The master is the record that survives — everything else in the group is absorbed into it and stops existing. So when the wrong record wins, the surviving entity carries the wrong stage, and every downstream integration holding the Customer's internal id now points at an absorbed record. No error is raised. The task reports completion.
Setting masterSelectionMode to SELECT_BY_ID is necessary — supply
masterRecordId under any other mode and it is accepted then disregarded by
design — but it is not sufficient. The behaviour above was observed with
SELECT_BY_ID set.
Treat master selection as advisory in a bulk run, and verify survivors
afterwards. You cannot reconstruct the mapping after the fact, because the
absorbed records are gone — so capture the intended
master id -> absorbed ids mapping before you submit.
Full notes, the four-step safe bulk-merge procedure, and the two verification
queries: docs/deduplication-notes.md
Both src/ files are AMD modules in SuiteScript 2.1 format, ready to drop into
the File Cabinet under SuiteScripts/.
map-reduce-template.js is a starting point — replace getInputData's query
and the reduce body with your own logic, and keep the summarize stage as-is.
governance-guard.js is a library module. Require it from any script type; it
has no entry point of its own.
Both pass node --check.
- netsuite-suiteql-cookbook — tested SuiteQL queries and the traps that make them silently return wrong answers.
MIT — see LICENSE.