# 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](/sdk/quick-start)
- For endpoints: a deployed REST API instance — see [Scripted endpoints](/console/rapi/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:

```javascript filename="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:

### Loader resource

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](/cli/scaffolding)). A `process()` run that filters by `resourceTags`, for example through an [application](/sdk/api/loader#applications) with tags, skips untagged resources, so add the tags it selects (the entry below has `api`).

```json filename="dcupl.lc.json"
{
  "resources": [
    {
      "url": "${baseUrl}/scripts/orders-summary.js",
      "type": "script",
      "scriptKey": "orders-summary",
      "tags": ["api"]
    }
  ]
}
```

### Programmatically

`dcupl.scripts.set(key, source)` stores the source string directly.

```typescript
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

```javascript filename="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 });
```

```typescript title="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.

```javascript filename="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);
```

```typescript title="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`.

```javascript filename="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;
});
```

```typescript title="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](/sdk/api/query-operators) 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](/sdk/api/query-operators#custom-operators)).

```javascript filename="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);
```

```typescript title="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.

```typescript title="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](/console/rapi/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.

```javascript filename="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);
```

```typescript title="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](/sdk/api/functions) and the [Dcupl class](/sdk/api/dcupl). 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](/console/rapi/scripted-endpoints#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).

```typescript title="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.

```javascript filename="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.

## Next steps

- [Scripted endpoints](/console/rapi/scripted-endpoints) — declare endpoints in a REST API template and call them
- [Scripts in the Console](/console/scripts) — write and run a script against your project before deploying it
- [Script types](/sdk/api/types#script-types) — `EndpointScriptRequest`, `ReadOnlyDcupl`, `RestApi.TemplateEndpoint`
- [Dcupl class → scripts](/sdk/api/dcupl#scripts) — the `scripts` API
