.md

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/core 2.0 (@dcupl/common ships 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:

scripts/orders-summary.js
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).

dcupl.lc.json
{
  "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.

Note

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

scripts/metadata.js
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 });
Call it
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.

scripts/refresh-products.js
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);
Call it
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.

scripts/by-attribute.js
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;
});
Use it in a query
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).

scripts/starts_with.js
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);
Use it in a query
// 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.

Function operator (deprecated)
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.

scripts/orders-summary.js
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);
Call it directly
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:

  • undefined becomes null
  • a string is sent as a JSON string ("…"), not as text/plain
  • Date becomes an ISO string, Map becomes {}; properties holding a function or symbol are dropped (inside an array they become null)
  • 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, structuredClone
  • console, 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).

Compile scripts in a SES compartment
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.

scripts/customer.js
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.