Docs / The language & scripts
Writing scripts
How a script is executed on this platform, for a reader who knows JS and nothing about the product. Do not edit this file — it is generated, and every example and every error message below is executed by the test suite, so the page cannot describe a platform that no longer behaves this way.
For what each function does, see the function reference. This page is about the model: what a script is, what it answers with, and what it may assume.
Every example on this page was run with this input:
{
"event": {
"id": "evt_2f1c9a",
"kind": "webhook",
"tenantId": "6a1b0f6e-0000-4000-8000-000000000001",
"environmentId": "6a1b0f6e-0000-4000-8000-000000000002",
"endpoint": "orders",
"body": {
"id": "o-1042",
"customer": "ada@example.com",
"items": [
{
"sku": "A-1",
"price": 20
},
{
"sku": "B-2",
"price": 10
}
]
},
"headers": {
"content-type": "application/json",
"x-source": "shop"
},
"receivedAt": "2026-03-04T09:15:00.000Z"
},
"settings": {
"crmConnection": "crm",
"pageSize": 50,
"signingSecret": "shared-secret"
}
}
A script is a program, not a module
A script is a program. Its statements run once, from top to bottom, and when the last one finishes the run is over. There is no entry point: nothing calls main, nothing looks for a default export, and the platform never searches your code for a function to invoke.
There are no modules either. import and export are refused before the script runs, and require does not exist. Code you want to share lives in a library script: the platform puts it in front of yours before the run starts, so its functions and variables are simply in scope — no import statement, no namespace.
Everything else you would write in a program works. Declare functions and call them, use a wrapper function if you want a private scope — but neither is required, and wrapping the whole script in one buys nothing.
A whole script, top to bottom
const order = input.event.body;
log("order received", { id: order.id });
function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price;
}
return sum;
}
return { id: order.id, total: total(order.items) };
Result — {"id":"o-1042","total":30}
No wrapper, no export, no entry point — the statements are the script.
What a script returns
A run has one result. Three rules decide it, in order:
- A top-level
returnwins — its value is the result, and the run stops there. - Otherwise, the final value of a top-level variable called
outputis the result. - Otherwise the run has no result, which is perfectly normal.
output only counts at the top level. A variable of that name inside a function is an ordinary local variable, and the platform never looks at it. The result becomes the run’s output: it is what the run detail shows, and — for a run triggered by a synchronous API call — what the caller can be answered with.
An explicit return wins
const output = "ignored";
return { ok: true };
Result — {"ok":true}
Or the final value of a top-level output
const output = { processed: 0 };
output.processed = input.event.body.items.length;
Result — {"processed":2}
output inside a function is just a local variable
function compute() {
const output = 42;
return output;
}
compute();
Result — the run succeeds with no output.
The platform reads the top-level output and this script has none — the one inside the function is an ordinary local variable, and returning it returns it to nobody.
A script may simply do its work
log("nothing to report");
Result — the run succeeds with no output.
input — the one global that carries data
input is the only global holding data. Its parts: input.event is what triggered the run; input.settings is the value the linked settings script returned — configuration as data, so environment-specific values never sit in the script (with no settings script linked it is undefined); and on a door that requires an identity, input.caller names the signed-in person — their id, email and roles, bound by the platform from the door’s own decision, absent everywhere else and never settable from any payload. Filter what you return by input.caller and you have an application backend.
input is frozen, at every depth. Assigning to any part of it throws a TypeError you can catch. Build what you need as a new value instead: an object literal for a few fields, JSON.parse(JSON.stringify(input.event.body)) for a deep copy.
What input.event carries depends on the door the run came through. These fields are always present: id, kind, tenantId, environmentId, body and receivedAt. kind is what to branch on when one script serves more than one door.
| Trigger | What causes it | input.event.body | Fields set |
|---|---|---|---|
webhook | An HTTP call arrived on one of the environment’s endpoints. The caller is answered immediately, before the script runs. | The request body — parsed when it is JSON, the raw text otherwise. | id, kind, tenantId, environmentId, endpoint, body, headers, receivedAt |
api_service | An HTTP call arrived on a synchronous endpoint, and the caller is waiting for this run’s answer — see respond. | The request body — parsed when it is JSON, the raw text otherwise. | id, kind, tenantId, environmentId, endpoint, body, headers, receivedAt |
scheduler | A schedule came due. Nothing else triggered it, so there is no caller and no payload from outside. | { schedule, cron, firedAt } — which schedule fired, its expression, and the minute it was due. | id, kind, tenantId, environmentId, body, receivedAt |
email_in | A message arrived in a mailbox the environment watches. One run per message. | The message envelope — sender, recipients, subject, body and attachment descriptions. | id, kind, tenantId, environmentId, endpoint, body, receivedAt |
system | Something went wrong in this environment — a run failed, queued work could not be delivered, or something was marked as failing. The script reacts to it. | The occurrence: { kind, tenantId, environmentId, scriptName?, runId?, taskId?, error?, at } — what broke and where, never the payload that caused it. | id, kind, tenantId, environmentId, body, receivedAt |
manual | Someone ran the script on demand — from the editor’s Test button or from the command line. | Whatever input was supplied for the test run. | id, kind, tenantId, environmentId, body, receivedAt |
Reading the trigger
const order = input.event.body;
return order.customer;
Result — "ada@example.com"
Changing it throws — catchably
try {
input.event.body.id = "changed";
} catch (error) {
return error.name + ": " + error.message;
}
Result — "TypeError: cannot mutate the read-only input"
The trigger is the run’s evidence: a resumed run must read the same payload it started with.
Build a new value instead
const order = input.event.body;
return { id: order.id, customer: order.customer, status: "processed" };
Result — {"id":"o-1042","customer":"ada@example.com","status":"processed"}
Calling the platform
Everything the platform offers — logging, HTTP calls, checkpoints, and the functions configured for your tenant — is called by its bare name and answers on the spot. There is no await and no .then, and nothing to hand a callback to: the language has no async, and Promise is not defined. (Callbacks themselves are ordinary — map and sort take one; it is the PLATFORM that never asks for one.)
That is not a simplification of something asynchronous. A platform call really does pause the run: the platform saves the run’s entire state, does the work, and continues your script with the result filled in. A run that is resumed — on another worker, an hour later — picks up the saved result instead of calling again, which is why a retried run never sends the same request twice.
You can call the platform from anywhere, a map or sort callback included. Prefer a plain loop when the list is not small: the calls happen one at a time either way, and only a loop can break — when you have enough, when an answer is bad, when the list turns out longer than you expected. The one way out of a callback is throw, which abandons the whole iteration.
What each function does — its arguments, its result, an example — is in the function reference. This page only fixes how calling works.
The results below come from running these examples against a connection that answers 200.
A call, written straight
const response = fetch({
connection: input.settings.crmConnection,
method: "GET",
path: "/contacts",
query: { pageSize: input.settings.pageSize }
});
log("contacts fetched", { count: response.body.items.length });
return "status " + response.status;
Result — "status 200"
No await, no callback — the next line already has the answer.
Call from a loop, not from a callback
const items = input.event.body.items;
const skus = [];
for (const item of items) {
log("item", { sku: item.sku });
skus.push(item.sku);
}
return skus;
Result — ["A-1","B-2"]
Cutting a long run into segments
log("first batch done");
checkpoint();
log("this line runs in the next segment");
Result — the run is saved here and continues in a later segment.
checkpoint() hands the worker back and continues where it left off, with every variable still in scope. Time spent parked costs no execution budget.
When a call does not simply succeed
A call that fails is not always over. The platform can make it again on its own, and for one kind of answer it can put your whole run to sleep and come back to it — two different things, worth telling apart before you configure either.
What follows is what the platform does; a configured call can change its share of it, and a script’s own fetch gets exactly these defaults.
Repeating — the platform makes the same call again, moments apart, while your run waits. It repeats when no answer arrived at all (the connection failed, timed out, or was refused) and when the answer means not now:
408,425,429— the far end asking to be called again.- Any 5xx except
501,505, which say the request itself will never work — for a call that is safe to repeat (below).
By default it repeats 3 times — so up to 4 calls in all — waiting longer before each one, and honouring a Retry-After the far end sends. Everything else is an ANSWER, and you decide what it means: 400, 401, 404, 422 are never repeated, and neither is any other 4xx, because asking again cannot change them.
Which calls repeat on a 5xx depends on what the call DOES. A call whose method promises nothing happens twice — GET, HEAD, OPTIONS, PUT, DELETE — or one you mark idempotent is repeated on a 5xx like any other transient answer. A call that changes something (a POST or a PATCH you have not marked) is repeated only on 408, 425, 429, never on a 5xx: the far end may have done the work before it failed to answer, and doing it twice is worse than telling you. Name a status under retry.status when you know the far end rolls back on it. The same line runs through a failed connection: one that never reached the far end at all — refused, unreachable, a name that did not resolve, a certificate the platform will not trust — is repeated for every call, while a connection that died after the request may have gone out is repeated only for a call that is safe to repeat.
Waiting is the other thing, and it is not the same: on a status you name as a long wait, the run is SUSPENDED for as long as the far end asked — its worker released, so the wait costs nothing — and the call is made again when it wakes. A status you have named that way is never repeated on the spot; waiting wins, because repeating would spend the wait holding a worker.
One wait is cut off at 24 hours, whatever the far end asks for and whatever you configure. The longest honest answer a business system gives is “come back after the daily quota resets”, and waiting that out is exactly what this is for; past a day the far end is not busy, it is broken, and a run parked on it should end loudly enough that somebody looks. A longer wait is taken as 24 hours and the run’s log says both what was asked for and what was taken. The same ceiling holds a script’s own checkpoint, which refuses a longer wait instead: waking a day later than you asked would do the work at the wrong time. A run that wants to wake up next week is a schedule, not a wait.
Two budgets bound every call. The one you can set is about SILENCE: how long a call may go without receiving any data before it is cut off — 5 minutes unless you say otherwise. It is not a limit on how long the call may take, so a large download over a slow link finishes as long as the bytes keep coming. Above it the platform holds an absolute limit of 60 minutes on one call and all its repeats together, which nothing you configure can lengthen.
A configured call can override any of this — how many times to repeat, statuses to add, statuses never to repeat, and the silence budget. What it cannot do is exceed the 60 minutes limit or ask for more than 10 repeats.
What a configured call answers
The functions configured for your tenant are the calls you should be making: someone chose the connection, the credential and the wording, and can change them without touching your script. What they can also choose is how much of the answer you have to deal with.
By default a call hands you both halves of what came back: { status, body }, with the body already read as data when the far end sent JSON. A status outside 200–299 arrives the same way — it is an answer to branch on, not a failure.
Whoever configures the call can narrow that, and most well-configured calls do:
- The statuses it accepts. Any other answer stops the run where the call was made, saying what came back — the status, what the far end sent, and how many times the platform had already asked. Write no status check: if the next line runs, the call was answered.
- What the answer IS. The value at a path inside the body, or the one record found in a collection. That value is then what you get — not an envelope around it. Several records where one was asked for stops the run rather than picking one, because which is right is a business question.
- What nothing means. A search that matches nothing, a status that says the record is not there (
404,410), or anullwhere the value should be — a far end’s way of saying it made no record — can be an ordinarynullyou branch on, or it can stop the run, naming what was searched for. Stopping is what happens unless you say otherwise, so a create that quietly made nothing is never handed on as a value. Which one is a decision made in the configuration, not in your script.
So a call configured this way reads as the thing it fetches, and the code around it is business — the example below runs against exactly such a function.
You cannot tell from the call site which of these a function does — that is the point, and it is why the same call can be made stricter later without touching a script. Test the function and read what comes back: the Test panel shows exactly what a script receives.
A find that answers the value itself
const subject = findServiceRequest({ key: "SR-1" });
return subject;
Result — "Login is broken"
No status check and no unwrapping: this function’s configuration accepts 200 and takes the value at a path, so the call answers the value — and any other status would have stopped the run here, saying what the far end sent.
A step, once taken, is never taken again
The platform saves every step a run takes. A run that is interrupted — by a restart, by a pause you asked for, by a wait on a busy system — continues from where it stopped, on any machine. It does not start over, and it does not repeat a step it already took.
So the ordinary tools work. new Date() gives you the current moment, Date.now() the same moment as a number, Math.random() a random one. Read the clock twice and you get two readings, seconds apart if that is how long the work took. What you read is saved with the run: a value read before a pause is still that value after it, however long the pause lasted.
input.event.receivedAt is still there and still useful — it is when the run’s trigger arrived, which is not the same thing as now, and for a scheduled run that was caught up late it can be well in the past.
What a script cannot see is the MACHINE it is running on. A date-and-time string must carry a timezone (2026-03-04T09:15:00Z), dates are UTC, and localeCompare takes the locale you want as an argument. Without that, the same script would mean different things on different nodes — and which node runs it is not something you chose.
A few constructs are excluded from the language, because they hide control flow the platform has to be able to save. They are refused before the script runs, with the line and column:
class · this · super · new.target · async/await · generators · import/export · tagged templates · getters/setters · with · eval · debugger · Function · directives · destructuring defaults · sparse arrays · computed object keys
The clock is yours to read
const startedAt = Date.now();
const arrivedAt = new Date(input.event.receivedAt);
return { waitedMs: startedAt - arrivedAt.getTime() >= 0, year: arrivedAt.getUTCFullYear() };
Result — {"waitedMs":true,"year":2026}
The run started at or after its trigger arrived — the two are different moments, and both are available.
Say which locale you mean
return ["Öl", "Zebra"].sort((a, b) => a.localeCompare(b, "sv")).join(",");
Result — "Zebra,Öl"
Swedish files Ö after Z; German files it with O. Name the one you want and the answer is the same on every machine.
Everything else is ordinary JS
const skus = input.event.body.items.map((item) => item.sku);
return { skus, joined: skus.join(", "), count: skus.length };
Result — {"skus":["A-1","B-2"],"joined":"A-1, B-2","count":2}
Objects, arrays, JSON, Math, Map, Set, regular expressions, try/catch, arrow functions, for…of — all as you know them.
Errors, and what catches them
throw ends the run and marks it failed. The error’s name, message and the line it came from are recorded with the run.
try/catch catches what you would expect — TypeError, ReferenceError, RangeError, and anything you throw yourself — and it also catches failed platform calls. A call that could not be made surfaces as a catchable error named PlatformError, carrying the platform’s own reason in its message. A call that reached the other side and got an unhappy answer does not throw at all: an HTTP call returns its status for you to branch on.
What try/catch never catches is the run’s budget. When a run runs out of time or memory the platform ends it — there is nothing left to catch, and that is deliberate: a script must not be able to trap its own kill switch.
A run that fails ends with one of these, recorded on the run:
PARSE_ERROR— The script could not be read as a program. The message says where.UNSUPPORTED_SYNTAX— The script used something the language excludes. The message names it and says where.UNCAUGHT_EXCEPTION— Something was thrown and nothing caught it — including your ownthrow.EXECUTION_LIMIT— The run used up its time or its work allowance. Split long work withcheckpoint().HEAP_LIMIT— The run held more data at once than it is allowed to. Work in batches.EXTERNAL_TIMEOUT— A platform call did not answer in time.EXTERNAL_FAILED— A platform call failed and nothing caught it. Wrap the call intry/catchto handle it yourself.RETRY_NOT_PERSISTED— The script asked to be paused in a run that cannot be saved and resumed.INTERNAL— The platform itself failed. Nothing in the script can cause or prevent this one.
Catching an ordinary error
try {
const missing = null;
return missing.id;
} catch (error) {
return error.name + ": " + error.message;
}
Result — "TypeError: cannot read properties of null (reading 'id')"
Catching a call that could not be made
try {
fetch({ connection: "unconfigured", method: "GET", path: "/contacts" });
} catch (error) {
logError("the call could not be made", { reason: error.message });
return error.name;
}
Result — "PlatformError"
The message names the reason the platform recorded — an unknown connection here.
Throwing ends the run
throw new Error("order " + input.event.body.id + " has no shipping address");
Result — the run fails: UNCAUGHT_EXCEPTION — Error: order o-1042 has no shipping address
The result, and what JSON allows
A script’s result becomes the run’s output. A run that produces no result at all is not a failure; it simply has no output.
For a run triggered by a synchronous API call, respond(value) answers the caller. It can be called once, the run keeps going afterwards, and the run’s own result is still its output. A run triggered any other way may call respond too — nobody is listening, and nothing breaks.
The output is stored as JSON, so the usual JSON rules apply at that edge: properties whose value is undefined are dropped, and there is no form for a bigint or for an object that refers to itself. A result the platform cannot represent fails the run rather than being silently trimmed, and says which of these it hit:
cyclic— The result refers to itself, directly or through a chain of properties.bigint— A bigint has no JSON form. Convert it to a string or a number first.undefined— The valueundefinedhas no JSON form, so it cannot be stored as an output.unrepresentable— Part of the result has no JSON form — a function, for instance.
undefined is not data
return JSON.stringify({ id: input.event.body.id, note: undefined });
Result — "{\"id\":\"o-1042\"}"
Answering a synchronous caller
respond({ received: input.event.body.id });
return "answered";
Result — "answered"
Budgets
Every segment of a run is bounded, so nothing can run away. These are the defaults a script gets when it asks for nothing; a script can be given a segment budget of its own — longer or shorter — up to a ceiling the deployment sets. A run that pauses at a checkpoint starts its next segment with a fresh budget, which is what lets a job of hours finish inside one measured in seconds.
- Time for one segment of a run: 60000 ms.
- Steps one run may take across all its segments: 5000000.
- Values one run may hold at once: 100000.
Common mistakes
Assumptions a reader brings from general JS, what each one actually does here, and what to write instead. Most are refused outright; a few run and simply cost more than they look like they do. Every row is executed by the test suite in both shapes.
Exporting an entry point
That the platform imports the script and calls a main function.
Instead of this:
export function main(input) {
return { ok: true };
}
You get — PARSE_ERROR — parse error: 'import' and 'export' may appear only with 'sourceType: "module"' (1:0)
Write this instead:
return { ok: true };
Why — A script is a program, not a module. Nothing imports it and nothing calls it — its statements are the run.
Assigning to module.exports
That the script is a CommonJS module whose export is the handler.
Instead of this:
module.exports = function (input) {
return { ok: true };
};
You get — UNCAUGHT_EXCEPTION — ReferenceError: module is not defined
Write this instead:
const order = input.event.body;
return { id: order.id };
Why — There is no module system and no wrapper around your code — module is simply a name nothing defines.
Awaiting a platform call
That a call which does I/O must return a promise.
Instead of this:
const response = await fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.status;
You get — PARSE_ERROR — parse error: 'await' is only allowed within async functions and at the top levels of modules. (1:17)
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.status;
Why — The pause happens beneath the call, not in the script: the platform saves the whole run and continues it with the result already in place.
Declaring an async function
That work involving I/O has to be wrapped in an async function.
Instead of this:
async function loadContacts() {
return fetch({ connection: "crm", method: "GET", path: "/contacts" });
}
return loadContacts();
You get — UNSUPPORTED_SYNTAX — unsupported syntax: async function at 1:0
Write this instead:
function loadContacts() {
return fetch({ connection: "crm", method: "GET", path: "/contacts" });
}
return loadContacts().status;
Why — There is nothing asynchronous to express: an ordinary function that calls the platform already does the whole job.
Building a promise
That values arriving later are wrapped in promises.
Instead of this:
return new Promise(function (resolve) {
resolve(fetch({ connection: "crm", method: "GET", path: "/contacts" }));
});
You get — UNCAUGHT_EXCEPTION — ReferenceError: Promise is not defined
Write this instead:
return fetch({ connection: "crm", method: "GET", path: "/contacts" }).body;
Why — Nothing in a script arrives later — a call returns its value on the next line, so there is no promise to build.
Chaining .then
That a call’s result has to be unwrapped before it can be used.
Instead of this:
const contacts = fetch({ connection: "crm", method: "GET", path: "/contacts" }).then(function (response) {
return response.body.items;
});
return contacts;
You get — UNCAUGHT_EXCEPTION — TypeError: fetch(...).then is not a function
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.body.items;
Why — A call returns the answer itself — there is no promise, so there is no .then on it, and no callback for the platform to run later.
Requiring a library
That an HTTP client — or anything else — is pulled in with require or an import.
Instead of this:
const axios = require("axios");
return axios.get("https://crm.example.com/contacts");
You get — UNCAUGHT_EXCEPTION — ReferenceError: require is not defined
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.body;
Why — There is no package manager and nothing to import. Platform functions are in scope by name — fetch is the one HTTP client, and it brings the connection’s credentials, retries and logging with it — and shared code comes from a library script the platform puts in front of yours.
Reading configuration from the environment
That secrets and settings live in environment variables.
Instead of this:
return process.env.CRM_TOKEN;
You get — UNCAUGHT_EXCEPTION — ReferenceError: process is not defined
Write this instead:
return input.settings.crmConnection;
Why — A script never handles a credential: settings carry configuration, and a connection carries its own secret, which the platform applies when the call is made.
Waiting with setTimeout
That waiting means scheduling a callback.
Instead of this:
setTimeout(function () {
log("an hour later");
}, 3600000);
You get — UNCAUGHT_EXCEPTION — ReferenceError: setTimeout is not defined
Write this instead:
checkpoint(3600000);
log("an hour later");
Why — A wait is not a callback here: checkpoint saves the run, frees the worker, and continues the very next line when the wait is over — even across a restart.
Logging to the console
That output goes to standard output.
Instead of this:
console.log("order received");
You get — UNCAUGHT_EXCEPTION — ReferenceError: console is not defined
Write this instead:
log("order received", { id: input.event.body.id });
Why — A run’s log is part of the run: lines are stored with it, levelled, searchable, and kept with the evidence of what happened.
Tagging a template with a function
That a function can be called by putting a template after its name.
Instead of this:
return slugify`order ${input.event.body.id}`;
You get — UNSUPPORTED_SYNTAX — unsupported syntax: TaggedTemplateExpression at 1:7
Write this instead:
return slugify(`order ${input.event.body.id}`);
Why — Backtick strings themselves work, holes included. What is not here is the tag form, where a template becomes an argument list for the function in front of it — write an ordinary call instead.
Declaring a class
That data is modelled with classes.
Instead of this:
class Order {
constructor(id) {
this.id = id;
}
}
return new Order("o-1").id;
You get — UNSUPPORTED_SYNTAX — unsupported syntax: ClassDeclaration at 1:0
Write this instead:
function order(id) {
return { id: id };
}
return order(input.event.body.id).id;
Why — Classes and this are excluded — the platform saves a run’s whole state between steps, and plain objects and functions are what it can save faithfully.
Putting an object into a string
That an object turns itself into text when concatenated.
Instead of this:
return "order: " + input.event.body;
You get — UNCAUGHT_EXCEPTION — UnsupportedOperation: operator + is not supported on objects — convert explicitly, e.g. with JSON.stringify
Write this instead:
return "order: " + JSON.stringify(input.event.body);
Why — Silently turning an object into [object Object] is how useless log lines happen. Say what you meant instead.
Changing the trigger
That the incoming payload is a working copy.
Instead of this:
input.event.body.status = "processed";
return input.event.body;
You get — UNCAUGHT_EXCEPTION — TypeError: cannot mutate the read-only input
Write this instead:
const order = { id: input.event.body.id, status: "processed" };
return order;
Why — The trigger is the run’s evidence of what it was asked to do, and a resumed run must read exactly what it started with.
Calling the platform inside a callback
That map is a way to make one call per item without thinking about the calls.
Instead of this:
const contacts = input.event.body.items.map((item) =>
fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }),
);
return contacts.length;
This runs — It works, and it is one call per item — in order, one after another, never at the same time. Ten items is fine. A thousand is a thousand calls, and if the far end starts refusing them the run parks and resumes once per call, which can take hours. Nothing warns you: the shape that costs an hour looks exactly like the shape that costs a second. And when the per-item work is only staging rows, there is no loop to pay for at all: dsPutMany stores the whole list in one call, dsGetMany reads many keys back in one, dsFetchInto reads every page of a source into a dataset without a loop at all, and dsMapInto/dsEnrich reshape or fill in a whole staged set the same way.
Write this instead:
const items = input.event.body.items;
const contacts = [];
for (const item of items) {
contacts.push(fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }));
}
return contacts.length;
// The loop can stop. `break` on a budget, a count, a bad answer —
// none of which a callback can say. From inside a callback the only
// way out is `throw`, which abandons the whole iteration.
Why — A map reads as a transformation and hides that each item is a call. A loop puts the calls in front of you, lets you stop early, and is the shape to reach for whenever the list is not small and known.
Summarising a dataset by reading it into the script
That counting staged rows means walking them and keeping a tally.
Instead of this:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const counts = {};
let at = 0;
for (;;) {
const page = dsPage("run:people", { offset: at, limit: 200 });
if (page.length === 0) { break; }
for (const row of page) {
counts[row.value.dept] = (counts[row.value.dept] || 0) + 1;
}
at = at + page.length;
}
return counts.Research;
This runs — It works, and on a few hundred rows it is fine. On a real staged set it pulls the whole set into the run a page at a time to produce three numbers — every row read, counted and thrown away. dsStats counts in one query without moving a row, dsCount answers a total the same way, and sendReport turns the whole report dataset into a readable summary without a script seeing a single row.
Write this instead:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const groups = dsStats("run:people", "dept");
// One GROUP BY where the rows are. Each group is the value as JSON
// text — "Research" arrives with its quotes.
return groups[0].count;
Why — Staged data lives outside the run so that its size is not the run’s problem. A tally written in script quietly pulls it back in — the one thing the dataset calls exist to avoid.
Reading a file into the script to parse it
That a stored CSV is read into the script and parsed there.
Instead of this:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return parseCsv(atob(readBlob(file).contentBase64)).length;
This runs — It works on a sample and refuses on the real thing: a script may hold a few hundred kilobytes, and readBlob answers BLOB_TOO_LARGE for an export measured in megabytes. dsFromCsv reads the file where it is stored, a batch at a time, into a dataset.
Write this instead:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return dsFromCsv(file, "run:contacts", { keyPath: "email" }).rows;
Why — A file’s contents are kept outside a run on purpose: a handle costs nothing to carry, a megabyte costs the run.