Scripting API
The designer and React runtime cover the standard CRUD, form, filter, and export workflows. Use the scripting API when an interceptor, action, or custom endpoint needs server-side JavaScript.
The Low-Code System provides a server-side JavaScript scripting engine for executing custom business logic within interceptors, custom endpoints, event handlers, background jobs, and background workers. Scripts run in a sandboxed environment with access to a database API backed by EF Core.
Scripts are wrapped in an async function, so await and top-level return are supported.
Designer Code Editor
JavaScript fields in the Low-Code Designer use a code editor with syntax highlighting and low-code-aware autocomplete. The editor is available for interceptors, custom endpoints, event handlers, background jobs, and background workers.
The Available context list shows the globals enabled for the current script type. The list is based on the scripting capability profile, so an application can disable services such as HTTP, email, files, or background jobs for a specific script type.
Autocomplete covers:
- Common globals such as
db,currentUser,emailSender,http,events, andjobs - Endpoint, event handler, background job, background worker, and interceptor context variables
- Dynamic entity names in
db.query(...),db.get(...), file, image, and attachment helpers - Dynamic entity properties inside query lambda parameters and query results
- Enum names and values through
enumsandenumValues - File and image field selectors through
fileFieldsandimageFields
fileFields is intentionally limited to File properties and imageFields is limited to Image properties. They are safe selector trees for file and image helpers, not lists of every entity property.
await files.save(fileFields.Acme.Campaigns.Campaign.Document, {
fileName: 'brief.pdf',
contentType: 'application/pdf',
base64: base64Content
});
Unified Database API (db)
The db object is the main entry point for all data operations.
Key Design Principles
- Immutable Query Builder — each query method returns a new builder instance. The original is never modified.
- Database-Level Execution — all operations (filters, aggregations, joins, set operations) translate to SQL via EF Core and Dynamic LINQ.
- No in-memory processing of large datasets.
db.query(entityName)is asynchronous. Alwaysawaitit before chaining query methods, andawaitthe terminal operation such astoList(),count(),first(), orsum().
// Immutable pattern — each call creates a new builder
var baseQuery = (await db.query('Entity')).where(x => x.Active);
var cheap = baseQuery.where(x => x.Price < 100); // baseQuery unchanged
var expensive = baseQuery.where(x => x.Price > 500); // baseQuery unchanged
Query API
Basic Queries
var productQuery = await db.query('LowCodeDemo.Products.Product');
var products = await productQuery
.where(x => x.Price > 100)
.orderBy(x => x.Price)
.take(10)
.toList();
var filteredProductQuery = await db.query('LowCodeDemo.Products.Product');
var result = await filteredProductQuery
.where(x => x.Price > 100 && x.Price < 500)
.where(x => x.StockCount > 0)
.orderByDescending(x => x.Price)
.skip(10)
.take(20)
.toList();
Query Methods
| Method | Description | Returns |
|---|---|---|
where(x => condition) |
Filter results | QueryBuilder |
orderBy(x => x.Property) |
Sort ascending | QueryBuilder |
orderByDescending(x => x.Property) |
Sort descending | QueryBuilder |
thenBy(x => x.Property) |
Secondary sort ascending | QueryBuilder |
thenByDescending(x => x.Property) |
Secondary sort descending | QueryBuilder |
skip(n) |
Skip n records | QueryBuilder |
take(n) |
Take n records | QueryBuilder |
toList() |
Execute and return array | Promise<object[]> |
count() |
Return count | Promise<number> |
any() |
Check if any matches exist | Promise<boolean> |
all(x => condition) |
Check if all records match | Promise<boolean> |
isEmpty() |
Check if no results | Promise<boolean> |
isSingle() |
Check if exactly one result | Promise<boolean> |
first() / firstOrDefault() |
Return first match or null | Promise<object\|null> |
last() / lastOrDefault() |
Return last match or null | Promise<object\|null> |
single() / singleOrDefault() |
Return single match or null | Promise<object\|null> |
elementAt(index) |
Return element at index | Promise<object\|null> |
select(x => projection) |
Project to custom shape | QueryBuilder |
join(entity, alias, condition) |
Inner join | QueryBuilder |
leftJoin(entity, alias, condition) |
Left join | QueryBuilder |
Supported Operators in Lambda
| Category | Operators |
|---|---|
| Comparison | ===, !==, >, >=, <, <= |
| Logical | &&, \|\|, ! |
| Arithmetic | +, -, *, /, % |
| String | startsWith(), endsWith(), includes(), trim(), toLowerCase(), toUpperCase() |
| Array | array.includes(x.Property) — translates to SQL IN |
| Math | Math.round(), Math.floor(), Math.ceil(), Math.abs(), Math.sqrt(), Math.pow(), Math.sign(), Math.truncate() |
| Null | != null, === null |
Variable Capture
External variables are captured and passed as parameters:
var minPrice = 100;
var config = { minStock: 10 };
var nested = { range: { min: 50, max: 200 } };
var query = await db.query('Entity');
var result = await query.where(x => x.Price > minPrice).toList();
var result2 = await query.where(x => x.StockCount > config.minStock).toList();
var result3 = await query.where(x => x.Price >= nested.range.min).toList();
Contains / IN Operator
var targetPrices = [50, 100, 200];
var query = await db.query('Entity');
var products = await query
.where(x => targetPrices.includes(x.Price))
.toList();
Select Projection
var productQuery = await db.query('LowCodeDemo.Products.Product');
var projected = await productQuery
.where(x => x.Price > 0)
.select(x => ({ ProductName: x.Name, ProductPrice: x.Price }))
.toList();
Joins
Explicit Joins
var orderLineQuery = await db.query('LowCodeDemo.Orders.OrderLine');
var orderLines = await orderLineQuery
.join('LowCodeDemo.Products.Product', 'p', (ol, p) => ol.ProductId === p.Id)
.take(10)
.toList();
// Access joined data via alias
orderLines.forEach(line => {
var product = line.p;
context.log(product.Name + ': $' + line.Amount);
});
Left Join
var orderQuery = await db.query('LowCodeDemo.Orders.Order');
var orders = await orderQuery
.leftJoin('LowCodeDemo.Customers.Customer', 'c', (o, c) => o.CustomerId === c.Id)
.toList();
orders.forEach(order => {
if (order.c) {
context.log('Has match: ' + order.c.Name);
}
});
LINQ-Style Join
var orderLineQuery = await db.query('LowCodeDemo.Orders.OrderLine');
orderLineQuery
.join('LowCodeDemo.Products.Product',
ol => ol.ProductId,
p => p.Id)
Join with Filtered Query
var productQuery = await db.query('LowCodeDemo.Products.Product');
var expensiveProducts = productQuery.where(p => p.Price > 100);
var orderLineQuery = await db.query('LowCodeDemo.Orders.OrderLine');
var orderLines = await orderLineQuery
.join(expensiveProducts,
ol => ol.ProductId,
p => p.Id)
.toList();
Set Operations
Set operations execute at the database level using SQL:
| Method | SQL Equivalent | Description |
|---|---|---|
union(query) |
UNION |
Combine, remove duplicates |
concat(query) |
UNION ALL |
Combine, keep duplicates |
intersect(query) |
INTERSECT |
Elements in both |
except(query) |
EXCEPT |
Elements in first, not second |
var productQuery = await db.query('LowCodeDemo.Products.Product');
var cheap = productQuery.where(x => x.Price <= 100);
var inStock = productQuery.where(x => x.StockCount > 0);
var affordableInStock = await cheap.intersect(inStock).toList();
var soldOutBudgetItems = await cheap.except(inStock).toList();
Aggregation Methods
All aggregations execute as SQL statements:
| Method | SQL | Returns |
|---|---|---|
sum(x => x.Property) |
SELECT SUM(...) |
Promise<number> |
average(x => x.Property) |
SELECT AVG(...) |
Promise<number> |
min(x => x.Property) |
SELECT MIN(...) |
Promise<any> |
max(x => x.Property) |
SELECT MAX(...) |
Promise<any> |
distinct(x => x.Property) |
SELECT DISTINCT ... |
Promise<any[]> |
groupBy(x => x.Property) |
GROUP BY ... |
QueryBuilder |
var productQuery = await db.query('LowCodeDemo.Products.Product');
var totalValue = await productQuery.sum(x => x.Price);
var avgPrice = await productQuery.where(x => x.StockCount > 0).average(x => x.Price);
var cheapest = await productQuery.min(x => x.Price);
GroupBy with Select
var campaignQuery = await db.query('LowCodeDemo.ContentStudio.Campaign');
var grouped = await campaignQuery
.groupBy(x => x.Status)
.select(g => ({
Status: g.Key,
Count: g.count(),
TotalBudget: g.sum(x => x.Budget),
AvgBudget: g.average(x => x.Budget),
MinBudget: g.min(x => x.Budget),
MaxBudget: g.max(x => x.Budget)
}))
.toList();
GroupBy Aggregation Methods
| Method | SQL |
|---|---|
g.Key |
Group key value |
g.count() |
COUNT(*) |
g.sum(x => x.Prop) |
SUM(prop) |
g.average(x => x.Prop) |
AVG(prop) |
g.min(x => x.Prop) |
MIN(prop) |
g.max(x => x.Prop) |
MAX(prop) |
g.toList() |
Get group items |
g.take(n).toList() |
Get first n items |
GroupBy with Items
var campaignQuery = await db.query('LowCodeDemo.ContentStudio.Campaign');
var grouped = await campaignQuery
.groupBy(x => x.Status)
.select(g => ({
Status: g.Key,
Count: g.count(),
Items: g.take(10).toList()
}))
.toList();
GroupBy Security Limits
| Limit | Default | Description |
|---|---|---|
MaxGroupCount |
500 | Maximum groups; set to null to disable this limit |
Math Functions
Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.):
var productQuery = await db.query('LowCodeDemo.Products.Product');
var products = await productQuery
.where(x => Math.round(x.Price) > 100)
.toList();
var result = await productQuery
.where(x => Math.abs(x.StockCount) < 10 && Math.floor(x.Price) >= 4)
.toList();
CRUD API
Direct CRUD methods on the db object:
| Method | Description | Returns |
|---|---|---|
db.get(entityName, id) |
Get by ID | Promise<object\|null> |
db.getList(entityName, take?) |
Get a list with an optional limit | Promise<object[]> |
db.getCount(entityName) |
Get count | Promise<number> |
db.count(entityName) |
Alias for db.getCount(entityName) |
Promise<number> |
db.exists(entityName) |
Check if any records exist | Promise<boolean> |
db.insert(entityName, entity) |
Insert new | Promise<object> |
db.update(entityName, entity) |
Update existing | Promise<object> |
db.delete(entityName, id) |
Delete by ID | Promise<void> |
Note: The
entityNameparameter can be either a dynamic entity (e.g.,"LowCodeDemo.Products.Product") or a reference entity (e.g.,"Volo.Abp.Identity.IdentityUser"). However,insert,update, anddeleteoperations only work on dynamic entities — reference entities are read-only.
// Get by ID
var product = await db.get('LowCodeDemo.Products.Product', id);
// Insert
var newProduct = await db.insert('LowCodeDemo.Products.Product', {
Name: 'New Product',
Price: 99.99,
StockCount: 100
});
// Update
var updated = await db.update('LowCodeDemo.Products.Product', {
Id: existingId,
Name: 'Updated Name',
Price: 149.99
});
// Delete
await db.delete('LowCodeDemo.Products.Product', id);
Script Context
Scripts receive a context object and common global shortcuts. Available services can be enabled or disabled per script type with capability profiles.
Common Services
| Global | Context property | Description |
|---|---|---|
db |
context.db |
Query and CRUD API |
user, currentUser |
context.currentUser |
Current user information and claims |
tenant, currentTenant |
context.currentTenant |
Current tenant information |
email, emailSender |
context.emailSender |
Email send and queue helpers |
config |
context.config |
Filtered application configuration reader |
http |
context.http |
Hardened outbound HTTP client |
auth, authorization |
context.authorization |
Permission checks |
settings |
context.settings |
Filtered setting provider |
features |
context.features |
Feature checks |
events |
context.events |
Distributed event publishing |
jobs |
context.jobs |
Dynamic background job enqueueing |
encryption |
context.encryption |
String encryption and decryption |
textTemplating |
context.textTemplating |
ABP text template rendering |
blob |
context.blob |
Base64 blob storage wrapper |
files |
context.files |
Low-code file field helper |
images |
context.images |
Low-code image field helper |
attachments |
context.attachments |
Record attachment helper |
fileFields |
context.fileFields |
File field selector tree |
imageFields |
context.imageFields |
Image field selector tree |
enums, enumValues |
enum registry | Low-code enum value registry |
log, logWarning, logError |
logging methods | Script logging |
Global helpers are also available:
| Helper | Description |
|---|---|
guid() |
Generates a GUID string |
userFriendlyError(message, code?, details?, data?) |
Throws a UserFriendlyException |
businessError(message, code?, details?, data?) |
Throws a BusinessException, or a UserFriendlyException when no code is given |
validationError(message, errors?) |
Throws an AbpValidationException |
entityNotFound(entityName?, id?) |
Throws an EntityNotFoundException |
authorizationError(message?, code?, data?) |
Throws an AbpAuthorizationException |
The error helpers are described in Error Handling.
Interceptor Context
Interceptors add args and commandArgs:
| Property / Method | Description |
|---|---|
commandArgs.data |
Entity data dictionary for create/update |
commandArgs.entityId |
Entity ID for update/delete |
commandArgs.commandName |
Create, Update, or Delete |
commandArgs.entityName |
Full entity name |
commandArgs.getValue(name) |
Get a property value |
commandArgs.setValue(name, value) |
Set a property value |
commandArgs.hasValue(name) |
Check whether the input contains a property |
commandArgs.removeValue(name) |
Remove a property from the input |
Set globalError to abort an operation with a user-facing error. It is answered like userFriendlyError(message):
if (!args.getValue('Name')) {
globalError = 'Name is required.';
}
To reject invalid input with a 400 response, use validationError:
if (!args.getValue('Name')) {
validationError('Name is required.', [{ message: 'Name is required.', members: ['Name'] }]);
}
Custom Endpoint Context
Custom endpoints add request globals and response helpers. See Custom Endpoints for details.
| Variable | Description |
|---|---|
request |
Full request object |
route, params |
Route values |
query |
Query string values |
body |
Request body |
headers |
Selected safe request headers |
Event, Job, and Worker Context
| Script type | Additional globals |
|---|---|
| Event handler | handler, event, eventName, eventData |
| Background job | job, jobName, jobData, jobJsonData |
| Background worker | worker, workerName |
Event handlers, background jobs, and background workers are configured in the Designer Actions section or in JSON descriptor files. See Script Actions for descriptors, examples, and dry-run testing.
Event handler example:
context.log('Received event ' + eventName);
if (eventData && eventData.campaignId) {
await jobs.enqueueAsync('SendCampaignSummary', {
campaignId: eventData.campaignId
});
}
Background job example:
var campaign = await db.get('Acme.Campaigns.Campaign', jobData.campaignId);
if (!campaign) {
userFriendlyError('Campaign not found.');
}
await email.queueAsync(jobData.to, 'Campaign summary', campaign.Name);
Background worker example:
var campaignQuery = await db.query('Acme.Campaigns.Campaign');
var staleCount = await campaignQuery
.where(campaign => campaign.Status === 0)
.count();
context.log('Stale draft campaigns: ' + staleCount);
Service Helpers
HTTP
The http helper supports outbound requests with timeout, response-size, host, and HTTPS policy checks.
| Method | Description |
|---|---|
http.getAsync(url, options?) |
GET |
http.postAsync(url, body?, options?) |
POST |
http.putAsync(url, body?, options?) |
PUT |
http.patchAsync(url, body?, options?) |
PATCH |
http.deleteAsync(url, options?) |
DELETE |
http.requestAsync(method, url, bodyOrOptions?, options?) |
Custom method |
Options include headers, query, timeoutMs, contentType, and responseType (json, text, or base64).
Authorization, Settings, Features, and Config
if (await auth.isGrantedAsync('Acme.Campaigns.Create')) {
var enabled = await features.isEnabledAsync('Acme.Campaigns');
var threshold = await settings.getIntAsync('Acme.Campaigns.Threshold', 10);
var baseUrl = config.get('ExternalApi:BaseUrl');
}
Events and Background Jobs
await events.publishAsync('Acme.Campaigns.CampaignCompleted', { id: campaignId });
await jobs.enqueueAsync('SendCampaignSummary', { campaignId: campaignId }, {
priority: 'Normal',
delayMs: 60000
});
Files, Images, and Attachments
The file helpers use low-code page services so permissions, file validation, linked-blob checks, and foreign access stay consistent with the runtime.
| Helper | Purpose |
|---|---|
files.parse(value) |
Parse a stored file value |
files.format(value, includeSize?) |
Format a file display value |
files.save(...) / images.save(...) |
Save file or image content |
files.get(...) / images.get(...) |
Read file or image content |
files.upload(entityName, fieldName, fileInput, options?) |
Upload field content |
attachments.list(...) |
List record attachments |
attachments.upload(...) / attachments.save(...) |
Upload a record attachment |
attachments.get(...) / attachments.download(...) |
Download a record attachment |
attachments.delete(...) |
Delete a record attachment |
File content is passed as base64 data. File operations are subject to configured read/write size limits.
Use fileFields and imageFields when you want typed selectors for file or image properties:
await files.save(fileFields.Acme.Campaigns.Campaign.Document, {
fileName: 'brief.pdf',
contentType: 'application/pdf',
base64: base64Content
});
var content = await images.get(
imageFields.Acme.Campaigns.Campaign.BannerImage,
campaignId
);
The selector path is based on the full entity name and the File or Image property name. Record-level attachments are entity-level, not property-level, so they use the attachments helper instead of field selectors.
The email and emailSender globals use the configured ABP IEmailSender.
| Method | Description |
|---|---|
email.sendAsync(to, subject, body) |
Send plain text email |
email.sendAsync(from, to, subject, body) |
Send plain text email with explicit sender |
email.sendHtmlAsync(to, subject, htmlBody) |
Send HTML email |
email.sendHtmlAsync(from, to, subject, htmlBody) |
Send HTML email with explicit sender |
email.queueAsync(to, subject, body) |
Queue plain text email |
email.queueAsync(from, to, subject, body) |
Queue plain text email with explicit sender |
email.queueHtmlAsync(to, subject, htmlBody) |
Queue HTML email |
email.queueHtmlAsync(from, to, subject, htmlBody) |
Queue HTML email with explicit sender |
Email operations validate the recipient address, apply allowed or blocked domain rules when configured, and enforce the per-execution email limit.
if (email.isAvailable) {
await email.queueAsync(
'ops@example.com',
'Campaign completed',
'Campaign ' + campaignId + ' completed.'
);
}
Test JavaScript Dry Run
The Designer can run JavaScript without saving it where the Test JavaScript panel is available. The built-in dry-run panel supports custom endpoints, interceptors, event handlers, background jobs, and background workers.
Dry-run execution returns the endpoint response or script status, logs, captured side effects, duration, and error diagnostics. When the script fails, the result also shows the HTTP status and the error response body that a caller would receive. See Testing Error Responses.
| Operation | Dry-run behavior |
|---|---|
| Database writes | Executed in a transaction and rolled back |
| File, image, and attachment operations | Captured as side effects without persisting files |
| Email send or queue | Captured as an email side effect; no email is sent |
| Event publish | Captured as an event side effect; no event is published |
| Background job enqueue | Captured as a job side effect; no job is enqueued |
| Outbound HTTP | Resolved from configured HTTP mocks; no real HTTP request is sent |
| Logs | Returned in the result |
| Errors | Returned with type, message, and diagnostics when available, plus the HTTP status and error response body |
For endpoint dry runs, the request method, path, route values, query values, headers, and body are supplied by the test panel. Endpoint authentication and permission metadata are checked against the current user. For interceptor dry runs, the test panel supplies command metadata and command data. For event handler dry runs, it supplies eventData. For background job and worker dry runs, it supplies the job or worker input JSON.
Configuration
Configure scripting limits with the LowCode:Scripting configuration section or AbpLowCodeScriptingOptions.
Configure<AbpLowCodeScriptingOptions>(options =>
{
options.Script.Timeout = TimeSpan.FromMinutes(1);
options.Script.MaxStatements = 100_000;
options.Script.MaxMemoryBytes = 128 * 1024 * 1024;
options.Script.MaxRecursionDepth = 64;
options.Query.MaxLimit = 10_000;
options.Query.DefaultLimit = 1000;
options.Query.MaxExpressionNodes = 200;
options.Query.MaxExpressionDepth = 10;
options.Query.MaxArraySize = 500;
options.Query.MaxGroupCount = 500;
options.Capabilities.Endpoint.EnableHttp = false;
});
Most numeric limits can be set to null to explicitly disable that limit. Keep the defaults for untrusted or tenant-authored scripts.
Capability Profiles
The same services are not required in every script type. Capability profiles let you disable services per execution type:
{
"LowCode": {
"Scripting": {
"Capabilities": {
"Interception": {
"EnableDb": true,
"EnableHttp": false
},
"Endpoint": {
"EnableDb": true,
"EnableHttp": true
},
"EventHandler": {
"EnableDb": true
},
"BackgroundJob": {
"EnableDb": true
},
"BackgroundWorker": {
"EnableDb": true
}
}
}
}
}
Each profile supports flags such as EnableDb, EnableCurrentUser, EnableCurrentTenant, EnableEmail, EnableConfig, EnableHttp, EnableAuthorization, EnableSettings, EnableFeatures, EnableEvents, EnableBackgroundJobs, EnableEncryption, EnableTextTemplating, EnableBlob, and EnableFiles.
Security
Sandbox Constraints
| Constraint | Default | Configurable |
|---|---|---|
| Script Timeout | 30 seconds | Yes |
| Max Statements | 100,000 | Yes |
| Memory Limit | 128 MB | Yes |
| Recursion Depth | 64 | Yes |
| Max Script Length | 500,000 characters | Yes |
| CLR Access | Disabled | No |
Query Security Limits
| Limit | Default | Description |
|---|---|---|
| MaxExpressionNodes | 200 | Max AST nodes per expression |
| MaxExpressionDepth | 10 | Max nesting depth |
| MaxLimit (take) | 10,000 | Max records per query |
| DefaultLimit | 1,000 | Default if take() is not specified |
| MaxArraySize (includes) | 500 | Max array size for IN operations |
| MaxGroupCount | 500 | Max groups in GroupBy |
Integration Limits
| Area | Default |
|---|---|
| HTTP timeout | 30 seconds |
| HTTP response size | 5 MB |
| HTTP requests per execution | 50 |
| HTTP blocked hosts | localhost and private IP ranges |
| Email sends per execution | 5 |
| Blob read/write size | 10 MB read, 5 MB write |
| Low-code file read/write size | 10 MB read, 5 MB write |
| Event publishes per execution | 10 |
| Background jobs per execution | 10 |
| Endpoint response body | 1 MB |
Property Whitelist
Only properties defined in the entity model can be queried. Accessing undefined properties throws a SecurityException.
SQL Injection Protection
All values are parameterized:
var malicious = "'; DROP TABLE Products;--";
// Safely treated as a literal string — no injection
var query = await db.query('Entity');
var result = await query.where(x => x.Name.includes(malicious)).count();
Blocked Features
The following are not allowed inside lambda expressions: typeof, instanceof, in, bitwise operators, eval(), Function(), new RegExp(), new Date(), console.log(), setTimeout(), globalThis, window, __proto__, constructor, prototype, Reflect, Proxy, Symbol.
Error Handling
Scripts report an error by calling one of the error helpers. Each helper throws the ABP exception it is named after, so a script error is handled exactly like the same exception thrown by application code: ABP exception handling decides the HTTP status and the error response body, localizes error codes, and applies your status code mappings.
Error Helpers
| Helper | Exception | Default HTTP status |
|---|---|---|
userFriendlyError(message, code?, details?, data?) |
UserFriendlyException |
403 |
businessError(message, code?, details?, data?) |
BusinessException; UserFriendlyException when code is omitted |
403 |
validationError(message, errors?) |
AbpValidationException |
400 |
entityNotFound(entityName?, id?) |
EntityNotFoundException |
404 |
authorizationError(message?, code?, data?) |
AbpAuthorizationException |
401 when the user is not signed in, 403 otherwise |
The default statuses are the ones ABP assigns to these exception types. The helpers stop the script; you don't need to throw their result.
userFriendlyError shows message to the user. details adds more text to the error response. When code is given and has localized text, the localized text is shown instead of message:
if (campaign.Status === 2) {
userFriendlyError('The campaign is already published.', null, 'Create a new campaign to make changes.');
}
businessError with a code throws a BusinessException. The caller sees the localized text of the error code, and the values in data fill its {name} placeholders. message is written to the server log. Define the text in a localization resource mapped to the code's namespace (see Using Error Codes); a code without localized text is answered with ABP's generic error message. When the script's own message should reach the caller together with a code, use userFriendlyError(message, code) instead. Without a code, businessError works like userFriendlyError and shows message:
if (order.Total > budget.Remaining) {
businessError('Budget is exceeded.', 'Acme.Campaigns:BudgetExceeded', null, {
remaining: budget.Remaining
});
}
validationError returns 400 with the listed validation errors. Each error is an object with a message and the members (input names) it applies to, or a plain string. A single error object can be passed instead of a list, and members can be a single name. When errors is omitted or lists nothing, message becomes the only validation error; when message is empty too, the error reads "The request is not valid.":
var errors = [];
if (!body.name) {
errors.push({ message: 'Name is required.', members: ['name'] });
}
if (body.budget < 0) {
errors.push({ message: 'Budget cannot be negative.', members: ['budget'] });
}
if (errors.length > 0) {
validationError('The campaign is not valid.', errors);
}
entityNotFound returns 404 with ABP's not-found message for the given entity name and id:
var campaign = await db.get('Acme.Campaigns.Campaign', route.id);
if (!campaign) {
entityNotFound('Acme.Campaigns.Campaign', route.id);
}
authorizationError is handled like any ABP authorization failure: 401 for an anonymous caller and 403 for a signed-in user by default:
if (!(await auth.isGrantedAsync('Acme.Campaigns.Publish'))) {
authorizationError('You are not allowed to publish campaigns.');
}
data is an object. Its properties are added to the exception data, the same way WithData(name, value) adds them in C#.
HTTP Status Code Mapping
An error code can be mapped to another HTTP status with AbpExceptionHttpStatusCodeOptions. The mapping applies to script errors wherever a script answers an HTTP request, including interceptors and custom endpoints:
Configure<AbpExceptionHttpStatusCodeOptions>(options =>
{
options.Map("Acme.Campaigns:BudgetExceeded", HttpStatusCode.Conflict);
});
With this mapping, businessError('Budget is exceeded.', 'Acme.Campaigns:BudgetExceeded') returns 409 instead of 403. See HTTP Status Code Mapping.
Where Script Errors Go
Every script type handles a failed script the same way. The script type does not choose the status or the error body; ABP exception handling does:
| Script type | Result of a failed script |
|---|---|
| Entity and page interceptors | The operation is aborted and the request is answered with the error, like an exception thrown by an application service |
| Custom endpoints | The request is answered with the same status and error body as an application service, and the endpoint's database changes are rolled back |
| Page backend filter scripts | The query fails; no filter value is produced, so no unfiltered data is returned |
| Event handlers, background jobs, and background workers | The exception is passed to the event bus, job, or worker infrastructure, which logs it and, where supported, retries; there is no HTTP response |
Calculated and rollup properties are compiled expressions, not scripts, so these rules do not apply to them.
Runtime Failures
A script that fails on its own is reported with the error code LowCode:ScriptExecutionFailed and HTTP status 500, unless that code is mapped to another status. This includes:
- An uncaught JavaScript error, such as
throw new Error('...')or reading a property ofundefined - A syntax error
- A script longer than
MaxScriptLength - The timeout, memory, statement count, or recursion depth limit
- A result that the script type cannot use
- An unexpected failure in a script service, such as an outgoing HTTP call that timed out
The caller receives only the LowCode:ScriptExecutionFailed code and its localized message. The original message, the failure reason, and the script line and column are written to the server log and shown by Test JavaScript. They are not sent to the caller unless the application sends exception details to clients (AbpExceptionHandlingOptions.SendExceptionsDetailsToClients).
Use an error helper for any error whose message the user should see. throw new Error('Valid email is required') is a runtime failure, so the caller gets a 500 response without that message.
Exceptions that ABP already handles pass through unchanged. These include the error helpers' exceptions, a database concurrency conflict (409), and any exception that has its own HTTP status.
Testing Error Responses
When a script fails in Test JavaScript, the result keeps the error diagnostics (type, code, message, line, column, details, validation errors, and data) and also shows:
- The HTTP status that the application's exception handling gives the error, including status code mappings
- The error response body that a caller receives (
code,message,details,data, andvalidationErrors)
For a failed custom endpoint test, the endpoint response shows the same status and error message. The status of an authorization error is resolved from the current user (401 or 403), even if the application's authorization handler would answer a real request differently, for example with a redirect to the login page.
Catching Errors
// Try-catch for safe execution
try {
var query = await db.query('Entity');
var products = await query.where(x => x.Price > 0).toList();
} catch (error) {
context.log('Query failed: ' + error.message);
}
Best Practices
- Use specific filters — avoid querying all records without
where() - Set limits — always use
take()to limit results - Validate early — check inputs at the start of scripts
- Use
first()for single results — instead oftoList()[0] - Keep scripts focused — one responsibility per interceptor
- Use
context.log()— neverconsole.log() - Handle nulls — check for null before property access
Examples
Inventory Check on Order Creation
// Pre-create interceptor for Order
var productId = context.commandArgs.getValue('ProductId');
var quantity = context.commandArgs.getValue('Quantity');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var product = await productQuery
.where(x => x.Id === productId)
.first();
if (!product) { entityNotFound('LowCodeDemo.Products.Product', productId); }
if (product.StockCount < quantity) {
userFriendlyError('Only ' + product.StockCount + ' items are in stock.');
}
context.commandArgs.setValue('TotalAmount', product.Price * quantity);
Inventory Overview (Custom Endpoint)
var productQuery = await db.query('LowCodeDemo.Products.Product');
var totalProducts = await productQuery.count();
var productsWithStock = await productQuery
.where(x => x.StockCount > 0).count();
var averagePrice = await productQuery
.where(x => x.Price > 0).average(x => x.Price);
return ok({
totalProducts: totalProducts,
productsWithStock: productsWithStock,
averagePrice: averagePrice
});