Scripts and script modes
A script is a piece of JavaScript you register under a key and let the dcupl instance run for you. The same script source can serve as a reusable function, a custom query operator, a sort routine, or — on a hosted REST API instance — an HTTP endpoint. Which of these it is comes from the mode the caller runs it in, not from the script itself.
Prerequisites
@dcupl/core2.0 (@dcupl/commonships the types)- A dcupl instance with data loaded — see the Quick start
- For endpoints: a deployed REST API instance — see Scripted endpoints
How a script runs
A script file is one function body. The instance compiles it once per mode and calls it with a fixed parameter list. The first parameter is always script_type, the mode's name; the remaining parameters depend on the mode. Because of that, a script normally branches on script_type and throws for modes it does not support:
if (script_type === 'endpoint') {
// dcupl, request, options are in scope here
return { ok: true };
}
throw new Error('unsupported script type: ' + script_type);There are two ways to register a script:
A type: 'script' resource in dcupl.lc.json fetches the file and registers it under its scriptKey. This is how the Console, the CLI and hosted instances load scripts. In a CLI workspace, dcupl generate script --file-name orders-summary.js --mode endpoint writes a starter for that mode to the workspace's scripts folder (dcupl/scripts/ by default) and adds the resource with a scriptKey derived from the file name (here orders-summary) and tags: [] (Scaffolding). A process() run that filters by resourceTags, for example through an application with tags, skips untagged resources, so add the tags it selects (the entry below has api).
{
"resources": [
{
"url": "${baseUrl}/scripts/orders-summary.js",
"type": "script",
"scriptKey": "orders-summary",
"tags": ["api"]
}
]
}dcupl.scripts.set(key, source) stores the source string directly.
dcupl.scripts.set(
'orders-summary',
`
if (script_type === 'endpoint') {
return { ok: true };
}
throw new Error('unsupported script type: ' + script_type);
`,
);Either way, one registry backs every mode: a key registered once is visible to scripts.execute, to the query engine (as an operator) and to the sort pipeline. dcupl.scripts.keys() lists what is registered, dcupl.scripts.get(key) returns the source, dcupl.scripts.remove(key) drops it everywhere.
The five modes
| Mode | Who calls it | Parameters after script_type |
What the script returns |
|---|---|---|---|
script |
dcupl.scripts.execute(key, options?) |
dcupl, options, context |
Any value, returned synchronously |
async_script |
dcupl.scripts.executeAsync(key, options?) |
dcupl, options, resolve, reject, context |
Nothing — call resolve(value) or reject(err) |
sort |
A query with sort.script |
options, items, context |
The reordered items array |
operator |
A query condition with a non-built-in operator |
query, item, context |
true to keep the item, false to drop it |
endpoint |
dcupl.scripts.executeEndpoint(key, request, options?) — on hosted instances, an HTTP request |
dcupl, request, options, context |
A plain value or a Promise — it becomes the JSON response body |
context is an alias of options (or {} when there are none) and is present in every mode. The ScriptType union also contains validator, which is reserved and not dispatched by any call site.
dcupl is the full instance in script and async_script — the script can read and write models, data, lists and queries. In endpoint mode it is a frozen read-only facade (see below), and sort and operator scripts get no dcupl at all.
script — a reusable function
if (script_type !== 'script') {
throw new Error('unsupported script type: ' + script_type);
}
// `dcupl` is the full instance here, `options` whatever the caller passed
const modelKey = options.modelKey || dcupl.models.keys()[0];
return dcupl.fn.metadata({ modelKey });const metadata = dcupl.scripts.execute('metadata', { modelKey: 'Product' });async_script — settle a Promise yourself
The call is wrapped in new Promise; the script must call resolve or reject. A script that never settles leaves the promise pending.
if (script_type !== 'async_script') {
throw new Error('unsupported script type: ' + script_type);
}
fetch(options.url)
.then((res) => res.json())
.then((rows) => {
dcupl.data.set(rows, { model: 'Product' });
// data.set only stages the rows; update() applies them
return dcupl.update().then(() => resolve({ updated: rows.length }));
})
.catch(reject);const result = await dcupl.scripts.executeAsync('refresh-products', { url: '/api/products' });sort — reorder query results
A query's sort.script names the script; the pipeline hands it the items after any attribute sort and uses the returned array as the new order. The script owns membership too: items it drops disappear from the result, and every returned object must keep its key.
if (script_type !== 'sort') {
throw new Error('unsupported script type: ' + script_type);
}
// `items` is the array to reorder, `options` comes from sort.script.options
const attribute = options.attribute || 'key';
return items.sort((a, b) => {
if (a[attribute] < b[attribute]) return -1;
if (a[attribute] > b[attribute]) return 1;
return 0;
});const products = dcupl.query.execute({
modelKey: 'Product',
queries: [],
sort: {
attributes: [],
order: [],
script: { key: 'by-attribute', options: { attribute: 'price' } },
},
});If sort.script.key is not registered the items pass through unchanged and the SDK logs one warning per query naming the key (Ignoring the sort script "X": no script is registered under that key). An attribute sort in the same sort still runs.
operator — a custom query operator
Any operator name that is not a built-in operator runs the script registered under that key, once per item. Return true to keep the item. Register the script before the query runs: a condition whose operator is neither built in nor registered throws an UnknownOperator error (see Custom operators).
if (script_type !== 'operator') {
throw new Error('unsupported script type: ' + script_type);
}
// `query` is the condition being evaluated, `item` the candidate row
const value = item[query.attribute];
return typeof value === 'string' && value.startsWith(query.value);// A loader resource registers the file under its scriptKey; programmatically:
dcupl.scripts.set('starts_with', startsWithSource); // the script above as a string
const dcuplProducts = dcupl.query.execute({
modelKey: 'Product',
queries: [{ attribute: 'name', operator: 'starts_with', value: 'dcupl' }],
});The example only illustrates the mode — a prefix match is built in: { attribute: 'name', operator: 'find', value: '/^dcupl/' }.
dcupl.query.registerCustomOperator(name, fn) still registers a plain function as an operator, but the SDK marks function operators as deprecated ("Use custom script instead") — prefer the script form above for new code. A script registered under the same key takes precedence over the function.
dcupl.query.registerCustomOperator('starts_with', (query, item) => {
const value = item[query.attribute];
return typeof value === 'string' && value.startsWith(query.value);
});Three rules apply to script operators that built-ins do not share: a built-in name (eq, in, gt, …) always wins over a script with the same key; invert is not applied to a script operator's result; and an item whose attribute value is undefined or null never reaches the script — the condition is false for it.
endpoint — answer an HTTP request
The endpoint mode exists for scripted endpoints on a hosted REST API instance: the app runner maps an HTTP route to a script and sends the script's return value back as JSON. You can also call it directly with dcupl.scripts.executeEndpoint, which is what the Console's Run panel does.
if (script_type === 'endpoint') {
// GET …/endpoints/orders-summary?customer=acme → request.query.customer
const { customer } = request.query;
const orders = dcupl.query.execute({
modelKey: 'Order',
queries: customer ? [{ attribute: 'customer', operator: 'eq', value: customer }] : [],
});
const total = dcupl.fn.aggregate({
modelKey: 'Order',
options: { attribute: 'amount', types: ['sum'] },
query: {
modelKey: 'Order',
queries: customer ? [{ attribute: 'customer', operator: 'eq', value: customer }] : [],
},
});
// a plain value or a Promise — it becomes the JSON response body
return { count: orders.length, total: total.sum, items: orders.slice(0, 10) };
}
throw new Error('unsupported script type: ' + script_type);const body = await dcupl.scripts.executeEndpoint('orders-summary', {
method: 'GET',
path: 'orders-summary',
params: {},
query: { customer: 'acme' },
body: undefined,
headers: {},
});The rest of this page is the contract an endpoint script is written against.
The endpoint contract
request — plain data
request is an EndpointScriptRequest: plain data built from the HTTP request. It has no methods.
| Field | Type | Value |
|---|---|---|
method |
'GET' | 'POST' |
The HTTP method |
path |
string |
The request path relative to the instance's endpoints/ prefix, no leading slash, no query string, e.g. orders/42 |
params |
Record<string, string> |
The values bound to the endpoint's :name segments, URL-decoded, e.g. { id: '42' } for orders/:id |
query |
Record<string, string | string[]> |
The query string; a key that appears more than once is an array. The host removes its own keys (api-key, Api-Key, apiKey, v) |
body |
unknown |
The parsed JSON body for POST, undefined for GET |
headers |
Record<string, string> |
Header names lower-cased, multi-valued headers joined with , . The host removes api-key, apikey, x-api-key, authorization, cookie and v |
A script cannot read the caller's API key and cannot forward it: the credentials never reach the script.
dcupl — the read-only facade
In endpoint mode dcupl is a ReadOnlyDcupl: a frozen, read-only view of the instance's current state, and every request gets the same object. Everything on it reads; nothing stages data, registers scripts or operators, or re-initialises the instance.
| Namespace | Methods |
|---|---|
dcupl.query |
one, many, execute, generate |
dcupl.fn |
pivot, aggregate, suggest, groupBy, facets, metadata |
dcupl.models |
get(modelKey), keys() |
dcupl.views |
get(viewKey), keys(options?) |
dcupl.scripts |
keys() |
The signatures match the instance methods of the same name — see Functions and the Dcupl class. Every result is a deep copy, so editing a returned item never changes instance state.
What is not there: dcupl.data, dcupl.lists, dcupl.init, dcupl.update, dcupl.scripts.set/remove and dcupl.query.registerCustomOperator. The facade is frozen at every level, so dcupl.data.update(...) is a TypeError ("Cannot read properties of undefined") rather than a silent no-op, and a script cannot replace a facade method for the next request. lists is absent on purpose: a DcuplList carries filter and sort state that every request would share — use query.execute and fn.* instead.
options — what the host passes
options is whatever the host hands in; {} when it passes nothing. On an app runner instance it is the endpoint's template entry: { path, scriptKey, description }.
Return value → response body
executeEndpoint is async: the script may return a plain value or a Promise, and the method resolves with the awaited result. On a hosted instance the runner serialises that value with JSON.stringify and sends it with content-type: application/json and status 200 (also for POST). JSON semantics apply:
undefinedbecomesnull- a string is sent as a JSON string (
"…"), not astext/plain Datebecomes an ISO string,Mapbecomes{}; properties holding a function or symbol are dropped (inside an array they becomenull)- a circular value fails with a
500{ name: 'TypeError', message }
There is no way to set the status code or response headers from a script in this version.
Errors
| What happened | executeEndpoint |
On a hosted instance |
|---|---|---|
| The key is not registered | Rejects with ScriptNotFoundError (name: 'ScriptNotFound') |
500 { name: 'ScriptNotFound', … } — the status report already lists it as EndpointScriptMissing |
| The script throws or returns a rejected Promise | Rejects with that error | 500 with body { name, message } — never a stack |
| The script does not settle in time | Never — the SDK does not time scripts out | 504 with body { name: 'EndpointTimeout', message } |
scripting.enabled is false on the instance |
Rejects with Error('Scripting is disabled for this dcupl instance'), the error execute throws |
Not configurable on an app runner instance; where it is off, the loader registers no scripts and every endpoint reports EndpointScriptMissing |
Check error.name, not instanceof: @dcupl/core bundles its own copy of @dcupl/common, so the ScriptNotFoundError class you import is not the class core throws.
Timeout
The SDK never times a script out: executeEndpoint in your own process waits as long as the script takes. On a dcupl-hosted runner, an app runner instance gives an endpoint call 5000 ms. A script that hasn't settled by then is answered with 504 and { name: 'EndpointTimeout', message }, and the instance keeps serving other requests. A self-hosted runner reads the limit from its ENDPOINT_TIMEOUT_MS environment variable (default 5000; see Timeout). The limit only helps scripts that await something: a CPU-bound loop holds the instance until it finishes, the same way a slow operator script would.
The sandbox
On an app runner instance, scripts run in a sandbox. The built-in JavaScript objects are frozen, so a script cannot change Array.prototype or any other built-in. A script sees:
- the standard intrinsics —
Array,JSON,Map,Set,Promise,RegExp,Intl, … Date,Math,URL,URLSearchParams,TextEncoder,TextDecoder,atob,btoa,structuredCloneconsole, which masks API keys in what you log- the mode's parameters:
script_type,dcupl,request,options,context
It does not see process, require, module, Buffer, fetch, setTimeout or any other timer, or any Node built-in. An unknown global reads as undefined, so process.getBuiltinModule('fs') is a TypeError and require('fs') or fetch(url) throws — do not rely on the exact error type, only on the call failing. The scripts of one instance share one global object (state left on globalThis survives until the instance restarts); different instances share nothing.
In your own process — browser, Node, the CLI — the default compiler is new Function in your realm and none of these limits apply. Treat any script you register there as trusted code. A host can swap the compiler through scripting.compile and switch scripts off entirely with scripting: { enabled: false }, which makes scripts.execute* throw and the loader skip script, operator and transformer resources with a ScriptingDisabled quality error.
The compiler behind that sandbox can be imported on its own: createCompartmentCompiler({ console, fetch? }) from @dcupl/common-internal/sandbox returns a ScriptCompiler that evaluates every script of one instance inside one SES Compartment with the globals listed above, and the sandbox entry loads nothing else from the package (not its zod schemas), so a worker that only compiles scripts stays small. The host must import 'ses' and call lockdown() once at process start: without SES the call throws, and without lockdown() the shared intrinsics are not frozen. Pass the console scripts may log to (the app runner passes one that masks API keys) and fetch only if scripts may make requests (an SSRF-guarded one).
import 'ses';
import { Dcupl } from '@dcupl/core';
import { createCompartmentCompiler } from '@dcupl/common-internal/sandbox';
lockdown();
const dcupl = new Dcupl({
scripting: { compile: createCompartmentCompiler({ console }) },
});Writing scripts that work in several modes
One file can serve more than one mode by branching on script_type. Each mode gets its own parameters, so running the same key as an operator and later as an endpoint does not mix them up, and scripts.set with a new source takes effect in every mode at once.
const byCustomer = (dcupl, customer) =>
dcupl.query.execute({
modelKey: 'Order',
queries: [{ attribute: 'customer', operator: 'eq', value: customer }],
});
if (script_type === 'endpoint') {
return byCustomer(dcupl, request.params.customer);
}
if (script_type === 'script') {
return byCustomer(dcupl, options.customer);
}
throw new Error('unsupported script type: ' + script_type);Editor support
@dcupl/common exports endpointScriptContextTypes, a single .d.ts string that declares script_type, dcupl, request, options, context, query, item, items, resolve and reject as globals, together with EndpointScriptRequest, EndpointScriptContext and ReadOnlyDcupl. Load it as an extra lib in Monaco (the Console does this for every script file) and a script gets completions without resolving @dcupl/common.
The typings describe the endpoint mode: dcupl is declared as the ReadOnlyDcupl facade and options as Record<string, unknown> in every mode. In a script or async_script file the real dcupl is the full instance (data, lists, update(), …), even though the editor does not complete those members.