steedos-server-logic
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 // Operation data (trigger) or input (function)
ctx.params.userId // Current user ID
ctx.params.spaceId // Current workspace ID
ctx.broker // Moleculer service broker
ctx.getObject(objectApiName) // Get object instance
ctx.getUser(userId, spaceId) // Get user session details
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);
// Insert (MUST include space)
await objects.orders.insert({ ...doc, space: ctx.params.spaceId });
// Update (triggers other triggers)
await objects.orders.update(id, doc);
// Direct update/insert (bypass triggers)
await objects.orders.directUpdate(id, doc);
await objects.orders.directInsert(doc);
// Delete and count
await objects.orders.delete(id);
const count = await objects.orders.count({ filters: [...] });
npm — Utility Libraries
const { _ } = npm; // lodash
const { moment } = npm; // moment.js
const { validator } = npm; // validator.js
const { axios } = npm; // HTTP client
// Also: filters, formData, mongodb, sequelize
global is an alias for npm.
db — MongoDB Client Instance
db.collection('my_collection').find({}).toArray()
Other Sandbox Variables
makeNewID() // Generate a new MongoDB ObjectId string
services // Moleculer services registry
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 | 触发器结构
# triggers/orders_validate.trigger.yml
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 # Before record creation
- afterInsert # After record creation
- beforeUpdate # Before record update
- afterUpdate # After record update
- beforeDelete # Before record deletion
- afterDelete # After record deletion
- beforeFind # Before query
- afterFind # After query
- afterFindOne # After findOne query
- afterCount # After count query
- beforeAggregate # Before aggregation
- afterAggregate # After aggregation
Trigger ctx.params | 触发器参数
// Common (all events)
ctx.params.objectName // Object API name
ctx.params.userId // Current user ID
ctx.params.spaceId // Current workspace ID
// beforeInsert / afterInsert
ctx.params.doc // New document being inserted
ctx.params.isInsert // true
// beforeUpdate / afterUpdate
ctx.params.id // Record ID being updated
ctx.params.doc // New values (partial)
ctx.params.previousDoc // Original values before update
ctx.params.isUpdate // true
// beforeDelete / afterDelete
ctx.params.id // Record ID being deleted
ctx.params.isDelete // true
// beforeFind / afterFind
ctx.params.query // Query parameters
ctx.params.isFind // true
Returning Doc Changes
In before* triggers, return { doc: {...} } to modify the document:
return {
doc: {
...ctx.params.doc,
modified_field: newValue
}
};
Trigger Examples | 触发器示例
Data Validation | 数据验证
# triggers/orders_validate.trigger.yml
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 | 自动填充字段
# triggers/orders_auto_fill.trigger.yml
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 | 级联操作
# triggers/projects_cascade.trigger.yml
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 | 状态变更通知
# triggers/contracts_notify.trigger.yml
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 | 函数结构
# functions/orders_approve_order.function.yml
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 // Function input parameters (from API body or caller)
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 集成
# functions/orders_sync_to_erp.function.yml
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 | 批量处理
# functions/orders_batch_approve.function.yml
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 | 从按钮调用函数
# In a .button.yml amis_schema:
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
spacewhen inserting records: Server-side inserts MUST includespace: ctx.params.spaceId - Use
before*for validation and auto-fill,after*for side effects (notifications, cascade updates) - Use
directUpdate/directInsertto 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
docwithpreviousDoc - 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