@dforge-core/dforge-mcp 0.1.0-rc.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/README.md +84 -27
- package/dist/server.js +2957 -616
- package/docs/creating-modules.md +11 -4
- package/package.json +11 -6
- package/resources/docs/conventions.md +22 -16
- package/resources/docs/dsl-reference.md +767 -0
- package/resources/schemas/entity.schema.json +8 -1
- package/resources/schemas/print-templates.schema.json +82 -0
- package/resources/schemas/reports.schema.json +4 -0
- package/resources/schemas/triggers.schema.json +59 -0
- package/skills/dforge-mcp-author/SKILL.md +269 -69
- package/skills/dforge-mcp-author/examples/matrix-budget/README.md +43 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_category.json +24 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_line.json +56 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/manifest.json +30 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/security/roles.json +9 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/seed-data/01-categories.json +8 -0
- package/skills/dforge-mcp-author/examples/matrix-budget/ui/data_views.json +42 -0
- package/skills/dforge-mcp-author/examples/simple-todo/README.md +38 -0
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +83 -0
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_list.json +43 -0
- package/skills/dforge-mcp-author/examples/simple-todo/logic/actions/mark_done.dsl +6 -0
- package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +32 -0
- package/skills/dforge-mcp-author/examples/simple-todo/security/roles.json +10 -0
- package/skills/dforge-mcp-author/examples/simple-todo/seed-data/01-lists.json +17 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/actions.json +11 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/data_views.json +35 -0
- package/skills/dforge-mcp-author/examples/simple-todo/ui/menus.json +28 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +397 -0
- package/skills/dforge-mcp-author/references/column-types.md +168 -0
- package/skills/dforge-mcp-author/references/conventions.md +177 -0
- package/skills/dforge-mcp-author/references/data-migration.md +270 -0
- package/skills/dforge-mcp-author/references/data-views.md +243 -0
- package/skills/dforge-mcp-author/references/excel-import.md +61 -0
- package/skills/dforge-mcp-author/references/field-types.md +144 -0
- package/skills/dforge-mcp-author/references/filters.md +326 -0
- package/skills/dforge-mcp-author/references/flags.md +73 -0
- package/skills/dforge-mcp-author/references/formulas.md +206 -0
- package/skills/dforge-mcp-author/references/jobs.md +149 -0
- package/skills/dforge-mcp-author/references/manifest.md +123 -0
- package/skills/dforge-mcp-author/references/menus.md +164 -0
- package/skills/dforge-mcp-author/references/number-sequences.md +117 -0
- package/skills/dforge-mcp-author/references/print-templates.md +159 -0
- package/skills/dforge-mcp-author/references/queries.md +312 -0
- package/skills/dforge-mcp-author/references/reports.md +398 -0
- package/skills/dforge-mcp-author/references/schema-import.md +331 -0
- package/skills/dforge-mcp-author/references/security.md +244 -0
- package/skills/dforge-mcp-author/references/settings.md +120 -0
- package/skills/dforge-mcp-author/references/traits.md +153 -0
- package/skills/dforge-mcp-author/references/translations.md +158 -0
- package/skills/dforge-mcp-author/references/validation-checklist.md +183 -0
- package/skills/dforge-mcp-author/scripts/xlsx_to_model.py +198 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
# dForge Action DSL — Reference
|
|
2
|
+
|
|
3
|
+
The dForge action DSL is a JavaScript ES5 subset (parsed via Esprima, executed via Jint) with dForge-specific extensions for record field access, batch processing, and platform service calls. Use this reference when authoring `logic/actions/*.dsl` files via `dforge_action_add`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## File structure
|
|
8
|
+
|
|
9
|
+
A `.dsl` file consists of four ordered, optional blocks (only `execute:` is required):
|
|
10
|
+
|
|
11
|
+
```dsl
|
|
12
|
+
params:
|
|
13
|
+
# Parameter declarations (one per line)
|
|
14
|
+
|
|
15
|
+
canExecute:
|
|
16
|
+
# Single-expression formula returning boolean
|
|
17
|
+
# Determines whether the action button is enabled for current record(s)
|
|
18
|
+
|
|
19
|
+
onBeforeStart:
|
|
20
|
+
# Pre-queue initialization (async / background actions only)
|
|
21
|
+
|
|
22
|
+
execute:
|
|
23
|
+
# Main logic — required
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Block markers are case-insensitive but conventionally lowercase. Whitespace-indented bodies follow each marker. Blocks must appear in the order above when present.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Execution modes
|
|
31
|
+
|
|
32
|
+
Specified in `ui/actions.json` per action; affects how the DSL body sees record context:
|
|
33
|
+
|
|
34
|
+
| Mode | DSL sees | Use when |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `single` | one record at a time via `[field]` syntax | per-record operations triggered on each selected row OR a single-record document |
|
|
37
|
+
| `each` | same as `single` but explicitly iterated | rare; prefer `single` |
|
|
38
|
+
| `batch` | the full selection via `records.*` collection | bulk operations where you want explicit `for x in records { ... }` |
|
|
39
|
+
|
|
40
|
+
**Mode-context rule:** `[field]` syntax is only valid in `single` / `each` modes. In `batch` mode, write `rec[field]` inside the loop. In actions invoked by the **scheduler** (per `logic/jobs.json`), there is NO current record at all — `[field]` is a hard error; you must use `query()` to fetch what you need.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Parameter declarations (`params:` block)
|
|
45
|
+
|
|
46
|
+
### Scalar form
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
paramName: fieldTypeCd [required|optional] ["description"] [k=v ...]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Common field types + their extra keys:
|
|
53
|
+
|
|
54
|
+
| `fieldTypeCd` | Extra keys | Example |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `text` | `maxLen` | `name: text required "Name" maxLen=100` |
|
|
57
|
+
| `textarea` | (none) | `notes: textarea optional "Notes"` |
|
|
58
|
+
| `number` | `min`, `max`, `step` | `quantity: number required "Quantity" min=1` |
|
|
59
|
+
| `date` | (none) | `due_date: date required "Due"` |
|
|
60
|
+
| `dateTime` | (none) | `scheduled_at: dateTime required "Scheduled at"` |
|
|
61
|
+
| `boolean` | (none) | `urgent: boolean optional "Urgent?"` |
|
|
62
|
+
| `dropdown` | `options` | `priority: dropdown required "Priority" options=low,medium,high` |
|
|
63
|
+
| `lookup` | (use ref form instead — see below) | |
|
|
64
|
+
| `file` | `accept` | `attachment: file optional "Attachment" accept=.pdf,.png` |
|
|
65
|
+
|
|
66
|
+
### Reference form (FK to another entity)
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
paramName: ref entityCd [required|optional] ["description"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```dsl
|
|
73
|
+
params:
|
|
74
|
+
supplier: ref supplier required "Supplier"
|
|
75
|
+
dest_warehouse: ref warehouse required "Destination Warehouse"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Access the linked record's fields via `params[paramName].targetField`:
|
|
79
|
+
|
|
80
|
+
```dsl
|
|
81
|
+
execute:
|
|
82
|
+
var supplierName = params[supplier].name
|
|
83
|
+
var maxDays = params[supplier].payment_terms
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Field access syntax
|
|
89
|
+
|
|
90
|
+
### Current record (single / each mode)
|
|
91
|
+
|
|
92
|
+
- `[fieldName]` — read or write a field on the current record
|
|
93
|
+
- `[refField].[targetField]` — navigate via a Reference (FK) column to a field on the linked entity
|
|
94
|
+
|
|
95
|
+
```dsl
|
|
96
|
+
# Read
|
|
97
|
+
if ([quantity] > [available_qty]) { error("Not enough stock") }
|
|
98
|
+
if ([supplier].[is_active] == false) { error("Supplier disabled") }
|
|
99
|
+
|
|
100
|
+
# Write
|
|
101
|
+
[status] = 'closed'
|
|
102
|
+
[closed_date] = now()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### DSL parameters
|
|
106
|
+
|
|
107
|
+
```dsl
|
|
108
|
+
[reference_no] = params[reference_no]
|
|
109
|
+
[total] = [unit_price] * params[quantity]
|
|
110
|
+
[customer_id] = params[customer].customer_id
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Batch mode (`records.*`)
|
|
114
|
+
|
|
115
|
+
| Expression | Returns |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `records.count()` | int — number of selected records |
|
|
118
|
+
| `records.ids` | array of all selected record PKs |
|
|
119
|
+
| `records[n][field]` | the nth selected record's field value |
|
|
120
|
+
| `for rec in records { rec[field] }` | iterate; per-record read/write via `rec[fieldName]` |
|
|
121
|
+
|
|
122
|
+
```dsl
|
|
123
|
+
execute:
|
|
124
|
+
for rec in records {
|
|
125
|
+
rec[status] = params[new_status]
|
|
126
|
+
rec[last_updated] = now()
|
|
127
|
+
}
|
|
128
|
+
info('Updated ' + records.count() + ' records.')
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Built-in functions (30)
|
|
134
|
+
|
|
135
|
+
All exposed by `ActionScriptEngine.cs` as host globals — no `import` needed.
|
|
136
|
+
|
|
137
|
+
### Database & data
|
|
138
|
+
|
|
139
|
+
#### `query(sql, params?) → array`
|
|
140
|
+
Execute a parameterized SELECT. Returns array of `{col: val}` dictionaries. SQL uses `@name` placeholders.
|
|
141
|
+
|
|
142
|
+
```dsl
|
|
143
|
+
var open = query(
|
|
144
|
+
'SELECT invoice_id, total FROM fin.invoice WHERE status = @s AND customer_id = @cid',
|
|
145
|
+
{ s: 'open', cid: [customer_id] }
|
|
146
|
+
)
|
|
147
|
+
if (open.length == 0) { exit('Nothing open', 'info') }
|
|
148
|
+
info('Open invoices: ' + open.length + ', total $' + open[0].total)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### `insert(entityCd, fields) → record`
|
|
152
|
+
Insert a row. Returns the inserted record as a dictionary (PKs populated, defaults applied, audit columns set).
|
|
153
|
+
|
|
154
|
+
```dsl
|
|
155
|
+
var note = insert('activity_note', {
|
|
156
|
+
parent_id: [id],
|
|
157
|
+
parent_entity: 'opportunity',
|
|
158
|
+
note: params[note_text],
|
|
159
|
+
author_id: [created_by]
|
|
160
|
+
})
|
|
161
|
+
info('Note ' + note.activity_note_id + ' created')
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The returned record can be passed to a subsequent `insert()` for FK chaining:
|
|
165
|
+
|
|
166
|
+
```dsl
|
|
167
|
+
var po = insert('purchase_order', { supplier_id: params[supplier].supplier_id })
|
|
168
|
+
insert('po_line', { po_id: po.purchase_order_id, product_id: [product_id], qty: 1 })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### `getRecord(entityCd, id) → record | null`
|
|
172
|
+
Fetch a single record by PK. Returns null if not found.
|
|
173
|
+
|
|
174
|
+
```dsl
|
|
175
|
+
var rec = getRecord('crm.customer', [customer_id])
|
|
176
|
+
if (rec == null) { error('Customer not found') }
|
|
177
|
+
info('Customer: ' + rec.name + ' — credit limit $' + rec.credit_limit)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
#### `callProc(name, args?) → record`
|
|
181
|
+
Call a stored procedure with named args. Returns the proc's result row (or rows if it RETURNS TABLE).
|
|
182
|
+
|
|
183
|
+
```dsl
|
|
184
|
+
var summary = callProc('fin.compute_balance', { account_id: [account_id], as_of: now() })
|
|
185
|
+
info('Balance: ' + summary.balance)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### `preloadRef(fkField)`
|
|
189
|
+
Eagerly load the referenced record so subsequent `[fkField].[targetField]` calls don't re-query. Only useful in `single` / `each` mode and only if you'll access ref-nav many times in one execute block.
|
|
190
|
+
|
|
191
|
+
```dsl
|
|
192
|
+
execute:
|
|
193
|
+
preloadRef('customer_id')
|
|
194
|
+
var name = [customer_id].[name]
|
|
195
|
+
var email = [customer_id].[email]
|
|
196
|
+
var phone = [customer_id].[phone]
|
|
197
|
+
sendEmail(email, 'Welcome ' + name, '...')
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Date / time
|
|
201
|
+
|
|
202
|
+
#### `now() → DateTime`
|
|
203
|
+
Current UTC timestamp.
|
|
204
|
+
|
|
205
|
+
```dsl
|
|
206
|
+
[completed_at] = now()
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### `addDays(date, days) → DateTime`
|
|
210
|
+
Add (or subtract with negative) integer days.
|
|
211
|
+
|
|
212
|
+
```dsl
|
|
213
|
+
[due_date] = addDays(now(), 30)
|
|
214
|
+
var lastWeek = addDays(now(), -7)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### `addSeconds(date, seconds) → DateTime`, `addMinutes(date, minutes) → DateTime`
|
|
218
|
+
Fractional-unit arithmetic.
|
|
219
|
+
|
|
220
|
+
```dsl
|
|
221
|
+
[token_expires_at] = addMinutes(now(), 15)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Messaging & notifications
|
|
225
|
+
|
|
226
|
+
#### `info(message, opts?)`, `warn(message, opts?)`
|
|
227
|
+
Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
|
|
228
|
+
|
|
229
|
+
```dsl
|
|
230
|
+
info('Created PO ' + po.po_number, {
|
|
231
|
+
links: [{ entity: 'purchase_order', id: po.purchase_order_id }]
|
|
232
|
+
})
|
|
233
|
+
warn('Stock below reorder point — review supplier')
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
#### `notify(userId, message)`
|
|
237
|
+
Send a notification to a specific user's inbox.
|
|
238
|
+
|
|
239
|
+
```dsl
|
|
240
|
+
notify([approver_id], 'New purchase request from ' + [requester_name] + ' awaiting approval')
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### `sendEmail(to, subjectOrTemplateCd, bodyOrData?)`
|
|
244
|
+
Two modes:
|
|
245
|
+
|
|
246
|
+
```dsl
|
|
247
|
+
# Raw mode: (to, subject, body)
|
|
248
|
+
sendEmail('ap@acme.com', 'Bill ' + [bill_number] + ' approved', 'Amount: $' + [amount])
|
|
249
|
+
|
|
250
|
+
# Template mode: (to, templateCd, dataObject) — template defined in module's email_templates/
|
|
251
|
+
sendEmail([customer_id].[email], 'order_confirmation', {
|
|
252
|
+
orderNumber: [order_number],
|
|
253
|
+
total: [total],
|
|
254
|
+
items: query('SELECT * FROM order_line WHERE order_id = @oid', { oid: [id] })
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### `error(message)`
|
|
259
|
+
**Throw** — aborts execution and rolls back the **entire transaction** including any prior `insert()` / writes in this `execute:` block. Use for validation failures.
|
|
260
|
+
|
|
261
|
+
```dsl
|
|
262
|
+
if ([rating] == null || [rating] < 1 || [rating] > 5) {
|
|
263
|
+
error('Rating must be 1-5')
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
#### `exit(message?, level?)`
|
|
268
|
+
Successful early exit — **commits** work done so far. `level`: `"info"` (default) | `"warning"` | `"danger"` | `"success"`.
|
|
269
|
+
|
|
270
|
+
```dsl
|
|
271
|
+
if ([status] == 'closed') { exit('Already closed — nothing to do', 'info') }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### File operations
|
|
275
|
+
|
|
276
|
+
#### `getFileBase64(fileField) → string`
|
|
277
|
+
Read a file column → base64. Max 10 MB.
|
|
278
|
+
|
|
279
|
+
```dsl
|
|
280
|
+
var b64 = getFileBase64([attachment])
|
|
281
|
+
var posted = callApi('https://upload.example.com', 'POST', { 'Content-Type': 'application/json' }, JSON.stringify({ file: b64 }))
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### `getFileUrl(fileField) → string`
|
|
285
|
+
Time-limited signed download URL (typically 15-min TTL).
|
|
286
|
+
|
|
287
|
+
```dsl
|
|
288
|
+
var url = getFileUrl([signed_contract])
|
|
289
|
+
notify([approver_id], 'Contract ready: ' + url)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### `getFileInfo(fileField) → object`
|
|
293
|
+
Returns `{ storagePath, fileName, mimeType, fileSize, extension, kind }`.
|
|
294
|
+
|
|
295
|
+
```dsl
|
|
296
|
+
var info = getFileInfo([receipt])
|
|
297
|
+
if (info.fileSize > 5 * 1024 * 1024) { error('Receipt larger than 5 MB') }
|
|
298
|
+
if (info.mimeType != 'application/pdf') { error('Receipt must be PDF') }
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### `download(url, fileName?)`
|
|
302
|
+
Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file.
|
|
303
|
+
|
|
304
|
+
```dsl
|
|
305
|
+
execute:
|
|
306
|
+
var url = getFileUrl([signed_invoice])
|
|
307
|
+
download(url, 'invoice-' + [invoice_number] + '.pdf')
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Configuration & secrets
|
|
311
|
+
|
|
312
|
+
#### `getSetting(settingCd) → any`
|
|
313
|
+
Folder-scoped module setting — walks the folder inheritance chain up to the module default.
|
|
314
|
+
|
|
315
|
+
```dsl
|
|
316
|
+
var prefix = getSetting('invoice_number_prefix') # e.g. "INV-"
|
|
317
|
+
var maxDays = getSetting('payment_due_days') # e.g. 30
|
|
318
|
+
[due_date] = addDays(now(), maxDays)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
#### `getSecret(secretCd) → string`
|
|
322
|
+
Decrypt a module-level secret (API key, password, etc.).
|
|
323
|
+
|
|
324
|
+
```dsl
|
|
325
|
+
var apiKey = getSecret('stripe_api_key')
|
|
326
|
+
callApi('https://api.stripe.com/v1/charges', 'POST', { Authorization: 'Bearer ' + apiKey }, body)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
#### `nextNumber(entityCd) → string`
|
|
330
|
+
Generate next document number per the entity's `numberSequence` config. Mostly used for **pre-generating** numbers before insert (e.g. for an audit/messaging trail). On INSERT, the platform auto-generates when the target column is empty — no manual call needed there.
|
|
331
|
+
|
|
332
|
+
```dsl
|
|
333
|
+
execute:
|
|
334
|
+
var poNum = nextNumber('purchase_order')
|
|
335
|
+
notify([approver_id], 'PO ' + poNum + ' awaiting your approval')
|
|
336
|
+
insert('purchase_order', { po_number: poNum, status: 'pending_approval', supplier_id: params[supplier].supplier_id })
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
For cross-module: `nextNumber('fin.invoice')`.
|
|
340
|
+
|
|
341
|
+
### External integration
|
|
342
|
+
|
|
343
|
+
#### `callApi(url, method, headers?, body?) → string`
|
|
344
|
+
HTTP request. Returns response body as string. JSON parsing is your responsibility.
|
|
345
|
+
|
|
346
|
+
```dsl
|
|
347
|
+
var resp = callApi(
|
|
348
|
+
'https://api.weather.gov/points/' + [latitude] + ',' + [longitude],
|
|
349
|
+
'GET',
|
|
350
|
+
{ 'Accept': 'application/json' }
|
|
351
|
+
)
|
|
352
|
+
var data = JSON.parse(resp)
|
|
353
|
+
[forecast_url] = data.properties.forecast
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### `callService(name, args?) → any`
|
|
357
|
+
Invoke a C#-registered DSL service (escape hatch for things the platform exposes but the DSL doesn't have a built-in for). Service signatures live in `dForge.Engine`'s service registry.
|
|
358
|
+
|
|
359
|
+
```dsl
|
|
360
|
+
var result = callService('studio.validate', { module_cd: [module_cd] })
|
|
361
|
+
if (!result.isValid) { error('Validation failed: ' + result.errors[0]) }
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Utility
|
|
365
|
+
|
|
366
|
+
#### `flush()`
|
|
367
|
+
Commit pending changes mid-script AND broadcast SSE updates to connected clients. **Async / background actions only** — in sync actions, everything commits at the end automatically.
|
|
368
|
+
|
|
369
|
+
```dsl
|
|
370
|
+
# Long-running background job that wants to surface progress
|
|
371
|
+
execute:
|
|
372
|
+
var total = records.count()
|
|
373
|
+
var done = 0
|
|
374
|
+
for rec in records {
|
|
375
|
+
rec[processed_at] = now()
|
|
376
|
+
done = done + 1
|
|
377
|
+
if (done % 100 == 0) { flush() } # progress update every 100 records
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
#### `tryParseJson(value) → any | null`
|
|
382
|
+
Parse JSON safely. Returns `null` on parse failure (vs `JSON.parse` which throws).
|
|
383
|
+
|
|
384
|
+
```dsl
|
|
385
|
+
var meta = tryParseJson([metadata_json])
|
|
386
|
+
if (meta != null && meta.priority == 'high') { notify([owner_id], 'High priority') }
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## JavaScript subset
|
|
392
|
+
|
|
393
|
+
Esprima parses ES5; Jint executes.
|
|
394
|
+
|
|
395
|
+
**Supported control flow:** `if / else`, `for`, `while`, `do...while`, `switch / case / default / break / continue`, `return` (only inside `function`), `try / catch / finally`, `throw`.
|
|
396
|
+
|
|
397
|
+
**Supported declarations:** `var`, `function`. (No `let`, no `const`.)
|
|
398
|
+
|
|
399
|
+
**Operators:** all standard arithmetic / comparison / logical / assignment, including ternary `? :`.
|
|
400
|
+
|
|
401
|
+
**Object & array literals:** `{ key: value }`, `[a, b, c]`. Property access `obj.prop` and `obj['prop']`. Array indexing `arr[i]`.
|
|
402
|
+
|
|
403
|
+
**Standard library exposed by Jint:** `Math.*`, `JSON.parse` / `JSON.stringify`, `Date`, `Array.prototype.*` (map / filter / forEach / reduce / etc.), `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `console.log` (logged to server stderr).
|
|
404
|
+
|
|
405
|
+
**NOT supported:** `let`, `const`, arrow functions (`=>`), `async` / `await`, destructuring (`const {a,b} = obj`), spread (`...`), template-literal-tagged calls, ES6+ class syntax, optional chaining (`?.`), nullish coalescing (`??`).
|
|
406
|
+
|
|
407
|
+
**SQL-style literals auto-rewritten** (for filter-string compatibility): `NULL → null`, `TRUE → true`, `FALSE → false`.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Common patterns
|
|
412
|
+
|
|
413
|
+
### 1. Validation + early-success exit
|
|
414
|
+
|
|
415
|
+
```dsl
|
|
416
|
+
canExecute:
|
|
417
|
+
[status] == 'new'
|
|
418
|
+
|
|
419
|
+
execute:
|
|
420
|
+
if ([rating] == null || [rating] < 3) {
|
|
421
|
+
exit('Skipped — not high-priority', 'info')
|
|
422
|
+
}
|
|
423
|
+
[status] = 'reviewed'
|
|
424
|
+
info('Marked as reviewed')
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### 2. Insert child + reference parent
|
|
428
|
+
|
|
429
|
+
```dsl
|
|
430
|
+
params:
|
|
431
|
+
note_text: textarea required "Note"
|
|
432
|
+
|
|
433
|
+
execute:
|
|
434
|
+
var note = insert('activity_note', {
|
|
435
|
+
parent_id: [id],
|
|
436
|
+
parent_entity: 'opportunity',
|
|
437
|
+
note: params[note_text],
|
|
438
|
+
author_id: [created_by]
|
|
439
|
+
})
|
|
440
|
+
info('Added note ' + note.activity_note_id)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### 3. Cross-entity query + loop
|
|
444
|
+
|
|
445
|
+
```dsl
|
|
446
|
+
canExecute:
|
|
447
|
+
[report_date] != null
|
|
448
|
+
|
|
449
|
+
execute:
|
|
450
|
+
var prevDate = addDays([report_date], -1)
|
|
451
|
+
var prev = query(
|
|
452
|
+
'SELECT entry_id, estimate_hours FROM pm.time_entry WHERE report_date = @d',
|
|
453
|
+
{ d: prevDate }
|
|
454
|
+
)
|
|
455
|
+
if (prev.length == 0) {
|
|
456
|
+
exit('No previous entries found', 'warning')
|
|
457
|
+
}
|
|
458
|
+
for (var i = 0; i < prev.length; i++) {
|
|
459
|
+
insert('time_entry', {
|
|
460
|
+
user_id: [user_id],
|
|
461
|
+
report_date: [report_date],
|
|
462
|
+
hours: prev[i].estimate_hours
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
info('Copied ' + prev.length + ' entries from ' + prevDate)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### 4. Batch action with loop + count message
|
|
469
|
+
|
|
470
|
+
```dsl
|
|
471
|
+
execute:
|
|
472
|
+
for rec in records {
|
|
473
|
+
rec[status] = 'closed'
|
|
474
|
+
rec[closed_date] = now()
|
|
475
|
+
rec[closed_by] = [current_user_id]
|
|
476
|
+
}
|
|
477
|
+
info('Closed ' + records.count() + ' item(s)')
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### 5. Settings-driven behavior
|
|
481
|
+
|
|
482
|
+
```dsl
|
|
483
|
+
execute:
|
|
484
|
+
var threshold = getSetting('high_value_threshold') # e.g. 10000
|
|
485
|
+
var ccApprover = getSetting('cc_approver_user_id')
|
|
486
|
+
if ([amount] >= threshold && ccApprover != null) {
|
|
487
|
+
notify(ccApprover, 'Large order requires your CC approval: $' + [amount])
|
|
488
|
+
[requires_cc_approval] = true
|
|
489
|
+
}
|
|
490
|
+
[status] = 'pending_approval'
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### 6. Sequential status workflow with audit
|
|
494
|
+
|
|
495
|
+
```dsl
|
|
496
|
+
canExecute:
|
|
497
|
+
[status] == 'draft'
|
|
498
|
+
|
|
499
|
+
execute:
|
|
500
|
+
insert('status_history', {
|
|
501
|
+
entity_id: [id],
|
|
502
|
+
from_status: [status],
|
|
503
|
+
to_status: 'submitted',
|
|
504
|
+
changed_by: [current_user_id],
|
|
505
|
+
changed_at: now()
|
|
506
|
+
})
|
|
507
|
+
[status] = 'submitted'
|
|
508
|
+
[submitted_at] = now()
|
|
509
|
+
notify([approver_id], 'Request ' + [request_number] + ' awaiting approval')
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### 7. Aggregating across child records (manual sum)
|
|
513
|
+
|
|
514
|
+
```dsl
|
|
515
|
+
execute:
|
|
516
|
+
var lines = query(
|
|
517
|
+
'SELECT qty, unit_price FROM po_line WHERE po_id = @id',
|
|
518
|
+
{ id: [id] }
|
|
519
|
+
)
|
|
520
|
+
var subtotal = 0
|
|
521
|
+
for (var i = 0; i < lines.length; i++) {
|
|
522
|
+
subtotal = subtotal + (lines[i].qty * lines[i].unit_price)
|
|
523
|
+
}
|
|
524
|
+
[subtotal] = subtotal
|
|
525
|
+
[tax] = subtotal * getSetting('default_tax_rate')
|
|
526
|
+
[total] = [subtotal] + [tax]
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
(For frequent recomputation, prefer a Formula column over an action — formulas evaluate automatically.)
|
|
530
|
+
|
|
531
|
+
### 8. Conditional FK creation (find-or-create)
|
|
532
|
+
|
|
533
|
+
```dsl
|
|
534
|
+
params:
|
|
535
|
+
customer_name: text required "Customer Name"
|
|
536
|
+
|
|
537
|
+
execute:
|
|
538
|
+
var existing = query(
|
|
539
|
+
'SELECT customer_id FROM crm.customer WHERE name = @n',
|
|
540
|
+
{ n: params[customer_name] }
|
|
541
|
+
)
|
|
542
|
+
var cid
|
|
543
|
+
if (existing.length > 0) {
|
|
544
|
+
cid = existing[0].customer_id
|
|
545
|
+
} else {
|
|
546
|
+
var c = insert('customer', { name: params[customer_name], is_active: true })
|
|
547
|
+
cid = c.customer_id
|
|
548
|
+
info('Created new customer: ' + params[customer_name])
|
|
549
|
+
}
|
|
550
|
+
[customer_id] = cid
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### 9. Email with template + computed body
|
|
554
|
+
|
|
555
|
+
```dsl
|
|
556
|
+
execute:
|
|
557
|
+
var items = query(
|
|
558
|
+
'SELECT description, qty, unit_price FROM order_line WHERE order_id = @oid',
|
|
559
|
+
{ oid: [id] }
|
|
560
|
+
)
|
|
561
|
+
var total = 0
|
|
562
|
+
for (var i = 0; i < items.length; i++) {
|
|
563
|
+
total = total + items[i].qty * items[i].unit_price
|
|
564
|
+
}
|
|
565
|
+
sendEmail([customer_id].[email], 'order_confirmation', {
|
|
566
|
+
customerName: [customer_id].[name],
|
|
567
|
+
orderNumber: [order_number],
|
|
568
|
+
items: items,
|
|
569
|
+
total: total,
|
|
570
|
+
shipBy: addDays(now(), 3)
|
|
571
|
+
})
|
|
572
|
+
[confirmation_sent_at] = now()
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### 10. File processing + validation
|
|
576
|
+
|
|
577
|
+
```dsl
|
|
578
|
+
execute:
|
|
579
|
+
var info = getFileInfo([receipt])
|
|
580
|
+
if (info == null) { error('Receipt is required') }
|
|
581
|
+
if (info.fileSize > 5 * 1024 * 1024) { error('Receipt larger than 5 MB') }
|
|
582
|
+
if (info.mimeType != 'application/pdf' && info.mimeType != 'image/png') {
|
|
583
|
+
error('Receipt must be PDF or PNG')
|
|
584
|
+
}
|
|
585
|
+
[receipt_uploaded_at] = now()
|
|
586
|
+
[receipt_size_bytes] = info.fileSize
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### 11. Notification fan-out to role members
|
|
590
|
+
|
|
591
|
+
```dsl
|
|
592
|
+
execute:
|
|
593
|
+
var managers = query(
|
|
594
|
+
'SELECT u.user_id FROM auth.user u ' +
|
|
595
|
+
'JOIN dForge.user_role ur ON ur.user_id = u.user_id ' +
|
|
596
|
+
'WHERE ur.role_cd = @role AND u.is_active = true',
|
|
597
|
+
{ role: 'pm.manager' }
|
|
598
|
+
)
|
|
599
|
+
for (var i = 0; i < managers.length; i++) {
|
|
600
|
+
notify(managers[i].user_id, 'New high-priority project: ' + [project_name])
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### 12. Soft-delete (when entity has `soft-delete` trait)
|
|
605
|
+
|
|
606
|
+
```dsl
|
|
607
|
+
canExecute:
|
|
608
|
+
[is_deleted] != true
|
|
609
|
+
|
|
610
|
+
execute:
|
|
611
|
+
[is_deleted] = true
|
|
612
|
+
[deleted_at] = now()
|
|
613
|
+
[deleted_by] = [current_user_id]
|
|
614
|
+
info('Item moved to trash. Restore via "Undelete" action.')
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Anti-patterns — what breaks at install or runtime
|
|
620
|
+
|
|
621
|
+
### ❌ Using `[field]` in batch mode
|
|
622
|
+
|
|
623
|
+
```dsl
|
|
624
|
+
# WRONG — batch mode has no "current record"
|
|
625
|
+
execute:
|
|
626
|
+
for rec in records {
|
|
627
|
+
[status] = 'closed' # ← this references the dispatch context, not rec
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
```dsl
|
|
632
|
+
# RIGHT
|
|
633
|
+
execute:
|
|
634
|
+
for rec in records {
|
|
635
|
+
rec[status] = 'closed'
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### ❌ Using `[field]` in a scheduled-job action
|
|
640
|
+
|
|
641
|
+
Jobs run as system user with no record context. The action must be entity-agnostic.
|
|
642
|
+
|
|
643
|
+
```dsl
|
|
644
|
+
# WRONG — fails at runtime with "no record context"
|
|
645
|
+
execute:
|
|
646
|
+
if ([overdue_count] > 0) { notify([owner_id], 'Overdue') }
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
```dsl
|
|
650
|
+
# RIGHT — fetch what you need via query
|
|
651
|
+
execute:
|
|
652
|
+
var overdue = query('SELECT owner_id, COUNT(*) AS n FROM invoice WHERE due_date < CURRENT_DATE GROUP BY owner_id', {})
|
|
653
|
+
for (var i = 0; i < overdue.length; i++) {
|
|
654
|
+
notify(overdue[i].owner_id, 'You have ' + overdue[i].n + ' overdue invoice(s)')
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### ❌ Top-level `return`
|
|
659
|
+
|
|
660
|
+
The DSL body is not wrapped in an IIFE — `return` at top level is a parse error.
|
|
661
|
+
|
|
662
|
+
```dsl
|
|
663
|
+
# WRONG
|
|
664
|
+
execute:
|
|
665
|
+
if ([status] == 'closed') { return }
|
|
666
|
+
[last_touched] = now()
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
```dsl
|
|
670
|
+
# RIGHT — use exit() for early success, error() for early failure
|
|
671
|
+
execute:
|
|
672
|
+
if ([status] == 'closed') { exit('Already closed', 'info') }
|
|
673
|
+
[last_touched] = now()
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### ❌ Assuming `error()` skips work done above
|
|
677
|
+
|
|
678
|
+
`error()` rolls back the **entire transaction** including prior `insert()` calls. Don't use it to "log and continue" — it's a hard abort.
|
|
679
|
+
|
|
680
|
+
```dsl
|
|
681
|
+
# WRONG — the insert below is rolled back when error fires
|
|
682
|
+
execute:
|
|
683
|
+
insert('audit_log', { event: 'attempted_close', record_id: [id] }) # rolled back!
|
|
684
|
+
if ([balance_due] > 0) { error('Cannot close — balance owing') }
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
```dsl
|
|
688
|
+
# RIGHT — validate first, then perform side-effects
|
|
689
|
+
execute:
|
|
690
|
+
if ([balance_due] > 0) { error('Cannot close — balance owing') }
|
|
691
|
+
insert('audit_log', { event: 'closed', record_id: [id] })
|
|
692
|
+
[status] = 'closed'
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### ❌ Calling `nextNumber()` then manually setting the field on insert
|
|
696
|
+
|
|
697
|
+
The platform auto-generates the number on INSERT when the target column is empty. Manual `nextNumber()` + manual field-set risks double-allocating sequence values if you forget to leave the field blank.
|
|
698
|
+
|
|
699
|
+
```dsl
|
|
700
|
+
# WRONG — wastes a sequence value
|
|
701
|
+
execute:
|
|
702
|
+
var n = nextNumber('purchase_order')
|
|
703
|
+
var po = insert('purchase_order', { po_number: n, ... }) # gets a DIFFERENT auto-number, n is wasted
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
```dsl
|
|
707
|
+
# RIGHT — let INSERT generate it
|
|
708
|
+
execute:
|
|
709
|
+
var po = insert('purchase_order', { supplier_id: params[supplier].supplier_id })
|
|
710
|
+
info('Created PO ' + po.po_number) # po_number was auto-populated
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Only use `nextNumber()` explicitly when you need the number BEFORE insert (e.g. to mention in a notification or message).
|
|
714
|
+
|
|
715
|
+
### ❌ Heavy work in a sync action without `flush()`
|
|
716
|
+
|
|
717
|
+
Sync actions block the UI until they finish. A 30-second action freezes the user's browser. For >1s work, either:
|
|
718
|
+
- Mark the action `background: true` and call `flush()` periodically for progress
|
|
719
|
+
- Move heavy lifting to a stored procedure called via `callProc()`
|
|
720
|
+
|
|
721
|
+
### ❌ String-concatenating into SQL
|
|
722
|
+
|
|
723
|
+
```dsl
|
|
724
|
+
# WRONG — injection-vulnerable + breaks on quotes
|
|
725
|
+
var q = query('SELECT * FROM customer WHERE name = ' + "'" + params[name] + "'", {})
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
```dsl
|
|
729
|
+
# RIGHT — use named placeholders
|
|
730
|
+
var q = query('SELECT * FROM customer WHERE name = @n', { n: params[name] })
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### ❌ Forgetting to bump `[updated_at]` on writes
|
|
734
|
+
|
|
735
|
+
The `audit` trait auto-maintains `last_updated` on INSERT/UPDATE if the column exists, but only via the standard data-access path. Direct `[field] = value` writes from DSL trigger the same path, so `last_updated` updates correctly. (No action needed — listed here as reassurance.)
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## Quick reference card
|
|
740
|
+
|
|
741
|
+
| Need to… | Use |
|
|
742
|
+
|---|---|
|
|
743
|
+
| Read current record's field | `[fieldName]` |
|
|
744
|
+
| Write current record's field | `[fieldName] = value` |
|
|
745
|
+
| Navigate FK | `[fkField].[targetField]` |
|
|
746
|
+
| Read DSL param | `params[paramName]` |
|
|
747
|
+
| Read FK param's field | `params[paramName].targetField` |
|
|
748
|
+
| Loop batch selection | `for rec in records { rec[field] }` |
|
|
749
|
+
| Count batch selection | `records.count()` |
|
|
750
|
+
| Validate + abort | `error('reason')` |
|
|
751
|
+
| Done early, commit | `exit('msg', 'info')` |
|
|
752
|
+
| Insert + use returned record | `var x = insert(entity, {...}); x.pk_id` |
|
|
753
|
+
| Run SQL | `query('SELECT ... WHERE c = @v', { v: value })` |
|
|
754
|
+
| Get setting | `getSetting('setting_cd')` |
|
|
755
|
+
| Send toast | `info('msg')`, `warn('msg')` |
|
|
756
|
+
| Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)` |
|
|
757
|
+
| Send inbox notification | `notify(userId, 'msg')` |
|
|
758
|
+
| Add days | `addDays(date, n)` |
|
|
759
|
+
| Now | `now()` |
|
|
760
|
+
|
|
761
|
+
---
|
|
762
|
+
|
|
763
|
+
## See also
|
|
764
|
+
|
|
765
|
+
- `dforge://schema/entity` — field types and column flags referenced in `params:` declarations
|
|
766
|
+
- `dforge://schema/jobs` — scheduled job → action binding (note the no-record-context caveat above)
|
|
767
|
+
- `dforge://docs/conventions` — broader module structure, FK+Reference pattern, traits, security model
|