| name | steedos-server-logic |
| description | Server-side triggers (.trigger.yml) and functions (.function.yml).
TRIGGER: .trigger.yml, .function.yml files in main/default/triggers/ and
functions/; server-side JavaScript on record CRUD; lifecycle hooks
(beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete,
afterDelete); trigger context (ctx.params.doc, previousDoc, id);
data validation, auto-fill, cascade updates, external API calls;
function properties (name, objectApiName, is_rest, script, isEnabled),
ctx.input, objects API.
SKIP: button UI → steedos-object-buttons; REST calls → steedos-server-api;
object definitions → steedos-objects.
|
Steedos Server Logic: Triggers & Functions
File Location | 文件位置
IMPORTANT: Triggers and functions must be in their own top-level folders, NOT inside object folders!
steedos-packages/
└── my-package/
└── main/default/
├── objects/
│ └── orders/
│ └── orders.object.yml
├── triggers/
│ ├── orders_validate.trigger.yml
│ └── orders_auto_fill.trigger.yml
└── functions/
├── orders_approve_order.function.yml
└── orders_sync_to_erp.function.yml
Shared Execution Context | 共享执行上下文
Both triggers and functions share the same sandbox variables:
ctx — Execution Context
ctx.params
ctx.params.userId
ctx.params.spaceId
ctx.broker
ctx.getObject(objectApiName)
ctx.getUser(userId, spaceId)
objects — All Steedos Object Instances
const records = await objects.orders.find({
filters: [['status', '=', 'active']],
fields: ['name', 'amount'],
top: 100,
skip: 0,
sort: 'created desc'
});
const record = await objects.orders.findOne(id);
await objects.orders.insert({ ...doc, space: ctx.params.spaceId });
await objects.orders.update(id, doc);
await objects.orders.directUpdate(id, doc);
await objects.orders.directInsert(doc);
await objects.orders.delete(id);
const count = await objects.orders.count({ filters: [...] });
npm — Utility Libraries
const { _ } = npm;
const { moment } = npm;
const { validator } = npm;
const { axios } = npm;
global is an alias for npm.
db — MongoDB Client Instance
db.collection('my_collection').find({}).toArray()
Other Sandbox Variables
makeNewID()
services
Part 1: Triggers | 触发器
Overview | 概述
Triggers are server-side JavaScript functions that execute automatically when data changes occur. They are defined as .trigger.yml files with inline handler code.
Trigger Structure | 触发器结构
name: orders_validate
listenTo: orders
when:
- beforeInsert
- beforeUpdate
isEnabled: true
type: code
locked: false
handler: |-
const { doc } = ctx.params;
if (doc.amount && doc.amount <= 0) {
throw new Error('Order amount must be greater than 0');
}
return { doc };
Trigger Properties | 触发器属性
| Property | Type | Required | Description |
|---|
name | string | Yes | ⚠️ MUST start with {listenTo}_ prefix, e.g. orders_validate |
listenTo | string | Yes | Object API name to listen to |
when | array | Yes | ⚠️ MUST only contain values from the Event Hooks list below. |
isEnabled | boolean | Yes | Enable/disable trigger |
type | string | Yes | ⚠️ MUST be code. No other value is valid. |
locked | boolean | No | Lock from editing |
handler | string | Yes | Inline JavaScript (YAML block scalar ` |
Event Hooks (when) | 事件钩子
⚠️ CRITICAL: Each value MUST be from the list below. Invalid values cause the trigger to fail silently.
when:
- beforeInsert
- afterInsert
- beforeUpdate
- afterUpdate
- beforeDelete
- afterDelete
- beforeFind
- afterFind
- afterFindOne
- afterCount
- beforeAggregate
- afterAggregate
Trigger ctx.params | 触发器参数
ctx.params.objectName
ctx.params.userId
ctx.params.spaceId
ctx.params.doc
ctx.params.isInsert
ctx.params.id
ctx.params.doc
ctx.params.previousDoc
ctx.params.isUpdate
ctx.params.id
ctx.params.isDelete
ctx.params.query
ctx.params.isFind
Returning Doc Changes
In before* triggers, return { doc: {...} } to modify the document:
return {
doc: {
...ctx.params.doc,
modified_field: newValue
}
};
Trigger Examples | 触发器示例
Data Validation | 数据验证
name: orders_validate
listenTo: orders
when:
- beforeInsert
- beforeUpdate
isEnabled: true
type: code
locked: false
handler: |-
const { doc, previousDoc, isInsert, isUpdate } = ctx.params;
if (doc.amount !== undefined && doc.amount <= 0) {
throw new Error('Order amount must be greater than 0');
}
if (doc.customer) {
const customer = await objects.customers.findOne(doc.customer);
if (!customer) {
throw new Error('Customer not found');
}
}
// Validate status transition (on update)
if (isUpdate && doc.status && previousDoc) {
const validTransitions = {
'draft': ['submitted', 'cancelled'],
'submitted': ['approved', 'rejected'],
'approved': ['shipped', 'cancelled'],
};
const allowed = validTransitions[previousDoc.status] || [];
if (!allowed.includes(doc.status)) {
throw new Error(
'Invalid status transition from ' + previousDoc.status + ' to ' + doc.status
);
}
}
return { doc };
Auto-Fill Fields | 自动填充字段
name: orders_auto_fill
listenTo: orders
when:
- beforeInsert
isEnabled: true
type: code
locked: false
handler: |-
const { doc } = ctx.params;
if (!doc.status) {
doc.status = 'draft';
}
if (!doc.order_date) {
doc.order_date = new Date();
}
if (doc.customer) {
const customer = await objects.customers.findOne(doc.customer);
if (customer) {
doc.customer_name = customer.name;
doc.customer_email = customer.email;
}
}
return { doc };
Cascade Operations | 级联操作
name: projects_cascade
listenTo: projects
when:
- afterUpdate
- beforeDelete
isEnabled: true
type: code
locked: false
handler: |-
const { doc, previousDoc, id } = ctx.params;
if (doc.status === 'closed' && previousDoc && previousDoc.status !== 'closed') {
const openTasks = await objects.tasks.find({
filters: [
['project', '=', id],
['status', '!=', 'completed'],
['status', '!=', 'cancelled']
]
});
for (const task of openTasks) {
await objects.tasks.directUpdate(task._id, {
status: 'cancelled',
cancelled_reason: 'Project closed'
});
}
}
if (ctx.params.isDelete) {
const incompleteCount = await objects.tasks.count({
filters: [
['project', '=', id],
['status', '!=', 'completed'],
['status', '!=', 'cancelled']
]
});
if (incompleteCount > 0) {
throw new Error(
'Cannot delete project with ' + incompleteCount + ' incomplete tasks'
);
}
}
Notification on Status Change | 状态变更通知
name: contracts_notify
listenTo: contracts
when:
- afterUpdate
isEnabled: true
type: code
locked: false
handler: |-
const { doc, previousDoc, id } = ctx.params;
if (doc.status && previousDoc && doc.status !== previousDoc.status) {
const fullRecord = await objects.contracts.findOne(id);
if (fullRecord.owner && fullRecord.owner !== ctx.params.userId) {
try {
await ctx.broker.call('notifications.send', {
to: fullRecord.owner,
title: 'Contract Status Updated',
body: 'Contract "' + fullRecord.name + '" status changed to ' + doc.status,
url: '/app/contracts/view/' + id,
from: ctx.params.userId
});
} catch (e) {
console.error('Failed to send notification:', e.message);
}
}
}
Part 2: Functions | 函数
Overview | 概述
Object functions are server-side JavaScript functions defined as .function.yml files. They encapsulate business logic and can be exposed as REST API endpoints, called from buttons, triggers, or other functions.
Function Structure | 函数结构
name: orders_approve_order
objectApiName: orders
description: Approve an order and update status
isEnabled: true
is_rest: true
locked: false
script: |-
const { input } = ctx;
const record = await objects.orders.findOne(input.id);
if (!record) {
throw new Error('Order not found');
}
if (record.status !== 'submitted') {
throw new Error('Only submitted orders can be approved');
}
await objects.orders.directUpdate(input.id, {
status: 'approved',
approved_at: new Date(),
approved_by: ctx.params.userId
});
return { message: 'Order approved successfully' };
Function Properties | 函数属性
| Property | Type | Required | Description |
|---|
name | string | Yes | ⚠️ MUST start with {objectApiName}_ prefix, e.g. orders_approve_order |
objectApiName | string | Yes | Associated object API name |
description | string | No | Human-readable description |
isEnabled | boolean | Yes | Enable/disable function |
is_rest | boolean | Yes | Expose as REST API endpoint |
locked | boolean | No | Lock from editing |
script | string | Yes | Inline JavaScript code (YAML block scalar ` |
Function-Specific Context | 函数专用上下文
ctx.input
API Endpoint | API 端点
When is_rest: true, the function is accessible at:
POST /api/v6/functions/{objectApiName}/{functionApiName}
GET /api/v6/functions/{objectApiName}/{functionApiName}
⚠️ The {functionApiName} in the URL is the function name with the {objectApiName}_ prefix removed.
Function name | objectApiName | API URL |
|---|
orders_approve_order | orders | /api/v6/functions/orders/approve_order |
leads_convert_lead | leads | /api/v6/functions/leads/convert_lead |
Function Examples | 函数示例
External API Integration | 外部 API 集成
name: orders_sync_to_erp
objectApiName: orders
description: Sync order to external ERP system
isEnabled: true
is_rest: true
locked: false
script: |-
const { input } = ctx;
const order = await objects.orders.findOne(input.id);
if (!order) {
throw new Error('Order not found');
}
const customer = await objects.customers.findOne(order.customer);
const fetch = require('node-fetch');
const response = await fetch(process.env.ERP_API_URL + '/orders', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.ERP_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_number: order.order_number,
customer_name: customer?.name,
amount: order.total_amount,
items: order.line_items
})
});
const result = await response.json();
await objects.orders.directUpdate(input.id, {
erp_sync_id: result.id,
erp_sync_status: 'synced',
erp_synced_at: new Date()
});
return { message: 'Synced to ERP', erpId: result.id };
Batch Processing | 批量处理
name: orders_batch_approve
objectApiName: orders
description: Approve multiple orders at once
isEnabled: true
is_rest: true
locked: false
script: |-
const { input } = ctx;
const { _ } = npm;
const ids = input.ids;
if (!ids || !_.isArray(ids) || ids.length === 0) {
throw new Error('No records selected');
}
let successCount = 0;
let failCount = 0;
const errors = [];
for (const id of ids) {
try {
const order = await objects.orders.findOne(id);
if (order && order.status === 'submitted') {
await objects.orders.directUpdate(id, {
status: 'approved',
approved_at: new Date(),
approved_by: ctx.params.userId
});
successCount++;
} else {
failCount++;
errors.push({ id, reason: 'Not in submitted status' });
}
} catch (e) {
failCount++;
errors.push({ id, reason: e.message });
}
}
return {
message: `Approved ${successCount} orders, ${failCount} failed`,
successCount,
failCount,
errors
};
Calling Functions from Buttons | 从按钮调用函数
amis_schema: |-
{
"type": "service",
"body": {
"type": "button",
"label": "Approve",
"onEvent": {
"click": {
"actions": [
{
"actionType": "ajax",
"api": {
"url": "/api/v6/functions/orders/approve_order",
"method": "post",
"requestAdaptor": "api.data = { id: api.body.recordId }",
"messages": { "success": "Approved" }
}
}
]
}
}
}
}
Best Practices | 最佳实践
- Always set
space when inserting records: Server-side inserts MUST include space: ctx.params.spaceId
- Use
before* for validation and auto-fill, after* for side effects (notifications, cascade updates)
- Use
directUpdate/directInsert to bypass triggers and avoid infinite loops
- Don't throw errors in
after* triggers unless absolutely necessary — the record is already saved
- Check for field changes before acting: Compare
doc with previousDoc
- Validate input early in functions: Check parameters and record existence before processing
- Return meaningful results from functions: Always return an object with a
message. ⚠️ The API returns the function's return value directly — no wrapping.
- Handle errors with throw: Use
throw new Error('message') for validation failures
- Keep triggers focused: One trigger per concern (validation, auto-fill, notifications)
- Wrap external API calls in try/catch to prevent blocking record saves