@dforge-core/dforge-mcp 0.1.0-rc.11 → 0.1.0-rc.13

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +75 -21
  3. package/dist/server.js +2405 -272
  4. package/docs/creating-modules.md +7 -3
  5. package/package.json +11 -6
  6. package/resources/docs/conventions.md +20 -14
  7. package/resources/schemas/entity.schema.json +8 -1
  8. package/resources/schemas/reports.schema.json +4 -0
  9. package/skills/dforge-mcp-author/SKILL.md +227 -95
  10. package/skills/dforge-mcp-author/examples/simple-todo/README.md +38 -0
  11. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +83 -0
  12. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_list.json +43 -0
  13. package/skills/dforge-mcp-author/examples/simple-todo/logic/actions/mark_done.dsl +6 -0
  14. package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +32 -0
  15. package/skills/dforge-mcp-author/examples/simple-todo/security/roles.json +10 -0
  16. package/skills/dforge-mcp-author/examples/simple-todo/seed-data/01-lists.json +17 -0
  17. package/skills/dforge-mcp-author/examples/simple-todo/ui/actions.json +11 -0
  18. package/skills/dforge-mcp-author/examples/simple-todo/ui/data_views.json +35 -0
  19. package/skills/dforge-mcp-author/examples/simple-todo/ui/menus.json +28 -0
  20. package/skills/dforge-mcp-author/references/action-dsl.md +397 -0
  21. package/skills/dforge-mcp-author/references/column-types.md +168 -0
  22. package/skills/dforge-mcp-author/references/conventions.md +177 -0
  23. package/skills/dforge-mcp-author/references/data-migration.md +270 -0
  24. package/skills/dforge-mcp-author/references/data-views.md +192 -0
  25. package/skills/dforge-mcp-author/references/excel-import.md +61 -0
  26. package/skills/dforge-mcp-author/references/field-types.md +144 -0
  27. package/skills/dforge-mcp-author/references/filters.md +326 -0
  28. package/skills/dforge-mcp-author/references/flags.md +73 -0
  29. package/skills/dforge-mcp-author/references/formulas.md +206 -0
  30. package/skills/dforge-mcp-author/references/jobs.md +149 -0
  31. package/skills/dforge-mcp-author/references/manifest.md +123 -0
  32. package/skills/dforge-mcp-author/references/menus.md +164 -0
  33. package/skills/dforge-mcp-author/references/number-sequences.md +117 -0
  34. package/skills/dforge-mcp-author/references/print-templates.md +159 -0
  35. package/skills/dforge-mcp-author/references/queries.md +312 -0
  36. package/skills/dforge-mcp-author/references/reports.md +398 -0
  37. package/skills/dforge-mcp-author/references/schema-import.md +331 -0
  38. package/skills/dforge-mcp-author/references/security.md +244 -0
  39. package/skills/dforge-mcp-author/references/settings.md +120 -0
  40. package/skills/dforge-mcp-author/references/traits.md +153 -0
  41. package/skills/dforge-mcp-author/references/translations.md +158 -0
  42. package/skills/dforge-mcp-author/references/validation-checklist.md +182 -0
  43. package/skills/dforge-mcp-author/scripts/xlsx_to_model.py +198 -0
@@ -0,0 +1,83 @@
1
+ {
2
+ "description": "Individual todo items",
3
+ "dbObject": "todo_item",
4
+ "toString": "{title}",
5
+ "traits": ["identity", "audit"],
6
+ "fields": {
7
+ "title": {
8
+ "dbDatatype": "varchar",
9
+ "fieldTypeCd": "text",
10
+ "flags": "VEM",
11
+ "maxLen": 200,
12
+ "orderNum": 10,
13
+ "description": "Title"
14
+ },
15
+ "description": {
16
+ "dbDatatype": "text",
17
+ "fieldTypeCd": "textarea",
18
+ "flags": "VE",
19
+ "orderNum": 20,
20
+ "description": "Description"
21
+ },
22
+ "priority": {
23
+ "dbDatatype": "varchar",
24
+ "fieldTypeCd": "dropdown",
25
+ "flags": "VEM",
26
+ "maxLen": 20,
27
+ "orderNum": 30,
28
+ "description": "Priority",
29
+ "params": {
30
+ "options": [
31
+ { "value": "low", "label": "Low", "color": "#6c757d" },
32
+ { "value": "medium", "label": "Medium", "color": "#ffc107" },
33
+ { "value": "high", "label": "High", "color": "#dc3545" }
34
+ ]
35
+ }
36
+ },
37
+ "done": {
38
+ "dbDatatype": "bool",
39
+ "fieldTypeCd": "checkbox",
40
+ "flags": "VEM",
41
+ "formula": "false",
42
+ "orderNum": 40,
43
+ "description": "Done"
44
+ },
45
+ "due_date": {
46
+ "dbDatatype": "date",
47
+ "fieldTypeCd": "date",
48
+ "flags": "VE",
49
+ "orderNum": 50,
50
+ "description": "Due Date"
51
+ },
52
+ "list_id": {
53
+ "dbDatatype": "cuid",
54
+ "flags": "EM",
55
+ "orderNum": 60,
56
+ "description": "List ID"
57
+ },
58
+ "list": {
59
+ "columnType": "R",
60
+ "fieldTypeCd": "lookup",
61
+ "flags": "VEM",
62
+ "orderNum": 65,
63
+ "description": "List",
64
+ "link": {
65
+ "entity": "todo_list",
66
+ "thisKey": "list_id",
67
+ "otherKey": "todo_list_id"
68
+ }
69
+ },
70
+ "owner_id": {
71
+ "fieldTypeCd": "user",
72
+ "flags": "VE",
73
+ "orderNum": 70,
74
+ "description": "Assigned To"
75
+ }
76
+ },
77
+ "references": {
78
+ "FK_TodoItem_List": {
79
+ "from": { "field": "list_id" },
80
+ "to": { "entity": "todo_list", "field": "todo_list_id" }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "description": "Lists that group todo items",
3
+ "dbObject": "todo_list",
4
+ "toString": "{name}",
5
+ "traits": ["identity", "audit"],
6
+ "fields": {
7
+ "name": {
8
+ "dbDatatype": "varchar",
9
+ "fieldTypeCd": "text",
10
+ "flags": "VEM",
11
+ "maxLen": 100,
12
+ "orderNum": 10,
13
+ "description": "List Name"
14
+ },
15
+ "color": {
16
+ "dbDatatype": "varchar",
17
+ "fieldTypeCd": "color",
18
+ "flags": "VEM",
19
+ "maxLen": 20,
20
+ "orderNum": 20,
21
+ "description": "Color"
22
+ },
23
+ "description": {
24
+ "dbDatatype": "text",
25
+ "fieldTypeCd": "textarea",
26
+ "flags": "VE",
27
+ "orderNum": 30,
28
+ "description": "Notes"
29
+ },
30
+ "items": {
31
+ "columnType": "S",
32
+ "fieldTypeCd": "grid",
33
+ "flags": "VEM",
34
+ "orderNum": 100,
35
+ "description": "Items",
36
+ "link": {
37
+ "entity": "todo_item",
38
+ "thisKey": "todo_list_id",
39
+ "otherKey": "list_id"
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,6 @@
1
+ canExecute:
2
+ [done] = false
3
+
4
+ execute:
5
+ [done] = true
6
+ info('Marked as done')
@@ -0,0 +1,32 @@
1
+ {
2
+ "packageFormat": 1,
3
+ "moduleId": "20000000-0000-0000-0000-000000000001",
4
+ "code": "simple_todo",
5
+ "version": "0.1.0",
6
+ "dbSchemaVersion": "0.0.1",
7
+ "displayName": "Simple Todo",
8
+ "description": "Minimal example module demonstrating the dForge package format",
9
+ "author": { "name": "dForge Examples" },
10
+ "license": "MIT",
11
+ "category": "Examples",
12
+ "tags": ["example", "todo"],
13
+ "icon": "bi-check-square",
14
+ "dependencies": {
15
+ "admin": ">=0.0.1"
16
+ },
17
+ "created": "2026-04-08",
18
+ "updated": "2026-04-08",
19
+ "entities": {
20
+ "todo_list": "./entities/todo_list.json",
21
+ "todo_item": "./entities/todo_item.json"
22
+ },
23
+ "dataViews": "./ui/data_views.json",
24
+ "menus": "./ui/menus.json",
25
+ "actions": "./ui/actions.json",
26
+ "security": {
27
+ "roles": "./security/roles.json"
28
+ },
29
+ "seedData": [
30
+ "./seed-data/01-lists.json"
31
+ ]
32
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "simple_todo.user": {
3
+ "description": "Can manage their own todo lists and items",
4
+ "rights": {
5
+ "todo_list": "SIUDC",
6
+ "todo_item": "SIUDC",
7
+ "action:mark_done": "E"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "entityCode": "todo_list",
3
+ "records": [
4
+ {
5
+ "todo_list_id": 1001,
6
+ "name": "Personal",
7
+ "color": "#4dabf7",
8
+ "description": "Personal tasks and errands"
9
+ },
10
+ {
11
+ "todo_list_id": 1002,
12
+ "name": "Work",
13
+ "color": "#51cf66",
14
+ "description": "Work-related tasks"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "mark_done": {
3
+ "label": "Mark Done",
4
+ "description": "Mark this todo item as completed",
5
+ "icon": "bi-check-circle",
6
+ "entityCode": "todo_item",
7
+ "executionMode": "each",
8
+ "script": "mark_done",
9
+ "isAsync": false
10
+ }
11
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "todo_list_grid": {
3
+ "label": "All Lists",
4
+ "description": "All todo lists",
5
+ "viewType": "grid",
6
+ "icon": "bi-list-ul",
7
+ "dataSources": [{
8
+ "entityCode": "todo_list",
9
+ "columns": [
10
+ { "column_cd": "name", "width": 200 },
11
+ { "column_cd": "color", "width": 80 },
12
+ { "column_cd": "description", "width": 400 }
13
+ ],
14
+ "order": ["name"]
15
+ }]
16
+ },
17
+ "todo_item_grid": {
18
+ "label": "All Items",
19
+ "description": "All todo items across all lists",
20
+ "viewType": "grid",
21
+ "icon": "bi-check-square",
22
+ "dataSources": [{
23
+ "entityCode": "todo_item",
24
+ "columns": [
25
+ { "column_cd": "title", "width": 300 },
26
+ { "column_cd": "list", "width": 150 },
27
+ { "column_cd": "priority", "width": 100 },
28
+ { "column_cd": "done", "width": 80 },
29
+ { "column_cd": "due_date", "width": 120 },
30
+ { "column_cd": "owner_id", "width": 150 }
31
+ ],
32
+ "order": ["due_date"]
33
+ }]
34
+ }
35
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "todo_menu": {
3
+ "label": "Todos",
4
+ "items": {
5
+ "lists": {
6
+ "orderNum": 1,
7
+ "label": "Lists & Items",
8
+ "icon": "check-square",
9
+ "children": {
10
+ "all_lists": {
11
+ "itemType": "V",
12
+ "label": "Lists",
13
+ "icon": "list-ul",
14
+ "dataViewCode": "todo_list_grid",
15
+ "orderNum": 1
16
+ },
17
+ "all_items": {
18
+ "itemType": "V",
19
+ "label": "All Items",
20
+ "icon": "check2-square",
21
+ "dataViewCode": "todo_item_grid",
22
+ "orderNum": 2
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,397 @@
1
+ # Action DSL Reference
2
+
3
+ Actions are dForge's business logic. They are scripts with three declarative blocks (`params:`, `canExecute:`, `execute:`) where the `execute:` block uses **JavaScript syntax** executed server-side via the Jint engine, with platform-provided global functions.
4
+
5
+ Lives in: `logic/actions/<action_name>.dsl`
6
+ Registered in: `ui/actions.json`
7
+
8
+ ## Structure
9
+
10
+ A DSL file has up to three blocks. All are optional (but `execute:` is needed to actually do something).
11
+
12
+ ```
13
+ params:
14
+ <name>: <type> [required] ["Label"]
15
+
16
+ canExecute:
17
+ <formula expression returning boolean>
18
+
19
+ execute:
20
+ <JavaScript with platform globals>
21
+ ```
22
+
23
+ ## The three blocks
24
+
25
+ ### `params:` — declare user inputs
26
+
27
+ Each param is one line: `name: type [required] ["Label"]`
28
+
29
+ ```
30
+ params:
31
+ new_stage: dropdown required "New Stage"
32
+ note: textarea "Follow-up Note"
33
+ adjustment_qty: number required "Adjustment Quantity"
34
+ reason: text required "Reason"
35
+ ```
36
+
37
+ Types are a subset of column field types: `text`, `textarea`, `number`, `currency`, `percent`, `checkbox`, `date`, `datetime`, `dropdown`, `lookup`, `user`.
38
+
39
+ Access in execute block: `params[param_name]` (bracket syntax, NOT dot syntax).
40
+
41
+ ### `canExecute:` — availability formula
42
+
43
+ A formula expression (same grammar as formula columns) that evaluates to true/false. When false, the action button is hidden or disabled in the UI.
44
+
45
+ ```
46
+ canExecute:
47
+ [status] = 'Draft'
48
+
49
+ canExecute:
50
+ [status] != 'Paid' AND [status] != 'Cancelled'
51
+
52
+ canExecute:
53
+ [stage] != 'Closed Won' AND [stage] != 'Closed Lost'
54
+
55
+ canExecute:
56
+ [status] = 'Submitted'
57
+
58
+ canExecute:
59
+ [quantity] >= 0
60
+ ```
61
+
62
+ Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc.
63
+
64
+ **Note**: `canExecute` uses single `=` for equality (formula syntax), NOT `==` (JS syntax).
65
+
66
+ ### `execute:` — the logic (JavaScript with platform globals)
67
+
68
+ This is **JavaScript** executed via the Jint engine. Standard JS syntax works: `var`, `for`, `if/else`, `+` concatenation, object literals, array indexing, etc.
69
+
70
+ On top of standard JS, the platform injects global functions and a special field-access syntax.
71
+
72
+ ## Field access and assignment
73
+
74
+ **Read a field** on the current record:
75
+
76
+ ```javascript
77
+ var name = [first_name] // bracket syntax — reads the field value
78
+ var total = [quantity] * [unit_price]
79
+ ```
80
+
81
+ `[field_name]` is shorthand for `__r.get('field_name')`. Both work; bracket syntax is cleaner.
82
+
83
+ **Write a field** on the current record:
84
+
85
+ ```javascript
86
+ [status] = 'Approved' // bracket assignment — updates the field
87
+ [approved_date] = now()
88
+ [quantity] = newQty
89
+ ```
90
+
91
+ `[field] = value` is shorthand for `__r.set('field', value)`. Both work.
92
+
93
+ **Access params**:
94
+
95
+ ```javascript
96
+ var qty = params[adjustment_qty] // bracket syntax; no quotes around param name in this DSL
97
+ var note = params[note] // no quotes around param name in this DSL
98
+ ```
99
+
100
+ `params[param_name]` — note: NO quotes around the param name inside brackets.
101
+
102
+ ## Platform global functions
103
+
104
+ ### Data operations
105
+
106
+ | Function | Returns | Description |
107
+ |---|---|---|
108
+ | `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
109
+ | `query('SQL', { arg: value })` | Array of row objects | Execute parameterized SQL. Use `@argName` placeholders. Schema-qualified table names (`crm.contact`). Access row fields with `row.field_name` or `row['field_name']`. |
110
+ | `getRecord('entity', id)` | Row object | Fetch a single record by primary key. |
111
+ | `preloadRef('fk_column')` | Row object | Load the referenced record for a FK column. Access its fields with `ref.get('field')`. |
112
+
113
+ ### Messaging
114
+
115
+ | Function | Description |
116
+ |---|---|
117
+ | `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
118
+ | `warn('message')` | Show a warning but continue execution. |
119
+ | `info('message')` | Show an informational message. |
120
+ | `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
121
+
122
+ ### Email
123
+
124
+ | Function | Description |
125
+ |---|---|
126
+ | `sendEmail(to, subject, htmlBody)` | Send a raw email. `to` is an email address string. |
127
+ | `sendEmail(to, subject, htmlBody, templateCode)` | Send a templated email (if email templates are configured). |
128
+
129
+ ### External API Calls
130
+
131
+ | Function | Returns | Description |
132
+ |---|---|---|
133
+ | `callApi(url, method, headers?, body?)` | String | Make an HTTP request to an external API. Returns response body as string. Parse JSON with `JSON.parse()`. Max 10 calls per script, 120s timeout, 5MB response limit. Content-Type defaults to `application/json` but can be overridden via headers. |
134
+ | `getFileBase64(fileFieldValue)` | String | Read a file from storage and return its content as a base64-encoded string. Use with `callApi()` to send files to AI APIs. Max 10 MB. Example: `getFileBase64([document_file])`. |
135
+ | `getFileUrl(fileFieldValue)` | String | Generate a temporary signed download URL (relative path) for a file field. Use in email templates or notifications — not for `callApi()`. |
136
+ | `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
137
+
138
+ ### Utilities
139
+
140
+ | Function | Returns | Description |
141
+ |---|---|---|
142
+ | `now()` | DateTime | Current date/time. Use for `date`, `datetime`, and `timestamp` fields. **Lowercase** — this is the execute-block date function. |
143
+ | `IF(condition, trueVal, falseVal)` | Any | Ternary helper. |
144
+ | `addDays(date, n)` | Date | Add `n` days to a date, e.g. `addDays(now(), 30)`. |
145
+ | `nextNumber('entity')` | String | Generate next value from a number sequence. Usually not needed — platform auto-fills on `insert()`. Use for pre-generating numbers. |
146
+ | `callProc('proc_name', { args })` | Result | Call a stored procedure. Args are passed as named parameters. |
147
+
148
+ > **Dates: `now()` in `execute:`, `TODAY()`/`NOW()` in formulas.** The `execute:` block runs as
149
+ > JavaScript (Jint) and exposes **lowercase `now()`** only — `TODAY()` and `NOW()` (uppercase) are
150
+ > **formula-engine** functions and are **undefined in `execute:`** (using them throws
151
+ > `'TODAY' is not defined` at install). Use `TODAY()`/`NOW()` only in `canExecute:`, formula
152
+ > columns, and other formula contexts; use `now()` everywhere inside `execute:`. (Uppercase `NOW()`
153
+ > is also fine *inside a raw SQL string* passed to `query()`, because that's SQL, not JS.)
154
+
155
+ ## Real examples from the codebase
156
+
157
+ ### Simple field update (status transition)
158
+
159
+ From `modules/fin/logic/actions/send_reminder.dsl`:
160
+
161
+ ```
162
+ canExecute:
163
+ [status] != 'Paid' AND [status] != 'Cancelled' AND [status] != 'Draft' AND [due_date] < TODAY()
164
+
165
+ execute:
166
+ [status] = 'Overdue'
167
+ notify([owner_id], 'Overdue reminder sent for invoice ' + [invoice_number])
168
+ ```
169
+
170
+ ### Params + validation + insert
171
+
172
+ From `modules/wms/logic/actions/adjust_stock.dsl`:
173
+
174
+ ```
175
+ params:
176
+ adjustment_qty: number required "Adjustment Quantity"
177
+ reason: text required "Reason"
178
+
179
+ canExecute:
180
+ [quantity] >= 0
181
+
182
+ execute:
183
+ var newQty = [quantity] + params[adjustment_qty]
184
+
185
+ if (newQty < 0) {
186
+ error('Adjustment would result in negative stock (' + newQty + ')')
187
+ }
188
+
189
+ var movementType = IF(params[adjustment_qty] >= 0, 'Adjustment', 'Write-off')
190
+
191
+ insert('stock_movement', {
192
+ movement_type: movementType,
193
+ warehouse_id: [warehouse_id],
194
+ product_id: [product_id],
195
+ quantity: IF(params[adjustment_qty] >= 0, params[adjustment_qty], params[adjustment_qty] * -1),
196
+ movement_date: now(),
197
+ reference_no: params[reason]
198
+ })
199
+
200
+ [quantity] = newQty
201
+ info('Stock adjusted by ' + params[adjustment_qty] + '. New balance: ' + newQty)
202
+ ```
203
+
204
+ ### Query + loop + multi-insert (complex orchestration)
205
+
206
+ From `modules/crm/logic/actions/create_quote_from_opp.dsl`:
207
+
208
+ ```
209
+ canExecute:
210
+ [stage] != 'Closed Won' AND [stage] != 'Closed Lost'
211
+
212
+ execute:
213
+ var lines = query('SELECT line_id, product_id, quantity, unit_price, discount_pct, description, sort_order FROM crm.opportunity_line WHERE opportunity_id = @oppId ORDER BY sort_order', { oppId: [opportunity_id] })
214
+
215
+ if (lines.length == 0) {
216
+ error('Cannot create quote: opportunity has no product lines. Add products first.')
217
+ }
218
+
219
+ var subtotal = 0
220
+ for (var i = 0; i < lines.length; i++) {
221
+ var line = lines[i]
222
+ subtotal = subtotal + (line.quantity * line.unit_price)
223
+ }
224
+
225
+ var quote = insert('quote', {
226
+ opportunity_id: [opportunity_id],
227
+ account_id: [account_id],
228
+ contact_id: [contact_id],
229
+ status: 'Draft',
230
+ quote_date: now(),
231
+ expiry_date: addDays(now(), 30),
232
+ subtotal: subtotal,
233
+ discount_pct: 0,
234
+ tax_pct: 0,
235
+ notes: 'Created from opportunity: ' + [opportunity_name]
236
+ })
237
+
238
+ for (var i = 0; i < lines.length; i++) {
239
+ var line = lines[i]
240
+ insert('quote_line', {
241
+ quote_id: quote.quote_id,
242
+ product_id: line.product_id,
243
+ quantity: line.quantity,
244
+ unit_price: line.unit_price,
245
+ discount_pct: line.discount_pct || 0,
246
+ description: line.description,
247
+ sort_order: line.sort_order || (i + 1)
248
+ })
249
+ }
250
+
251
+ info('Quote ' + quote.quote_number + ' created from opportunity ' + [opportunity_name])
252
+ notify([owner_id], 'Quote ' + quote.quote_number + ' created for ' + [opportunity_name])
253
+ ```
254
+
255
+ ### Reference preloading + email
256
+
257
+ From `modules/crm/logic/actions/approve_quote.dsl`:
258
+
259
+ ```
260
+ canExecute:
261
+ [status] = 'Sent'
262
+
263
+ execute:
264
+ [status] = 'Accepted'
265
+ notify([owner_id], 'Quote ' + [quote_number] + ' has been accepted')
266
+
267
+ var contact = preloadRef('contact_id')
268
+ if (contact.get('email') != null) {
269
+ sendEmail(contact.get('email'), 'Your quote ' + [quote_number] + ' is confirmed', '<p>Dear ' + contact.get('first_name') + ',</p><p>Your quote has been accepted.</p>')
270
+ }
271
+ ```
272
+
273
+ ### Query + update (cross-entity stock management)
274
+
275
+ From `modules/wms/logic/actions/receive_goods.dsl`:
276
+
277
+ ```
278
+ canExecute:
279
+ [status] = 'Approved'
280
+
281
+ execute:
282
+ var poLines = query("SELECT line_id, product_id, quantity FROM purchase_order_line WHERE purchase_order_id = @poId", { poId: [purchase_order_id] })
283
+
284
+ for (var i = 0; i < poLines.length; i++) {
285
+ var line = poLines[i]
286
+ var productId = line['product_id']
287
+ var qty = line['quantity']
288
+
289
+ insert('stock_movement', {
290
+ movement_type: 'Receipt',
291
+ warehouse_id: [warehouse_id],
292
+ product_id: productId,
293
+ quantity: qty,
294
+ movement_date: now(),
295
+ reference_no: [po_number]
296
+ })
297
+
298
+ var existing = query("SELECT stock_id, quantity FROM stock WHERE warehouse_id = @wh AND product_id = @prod", { wh: [warehouse_id], prod: productId })
299
+
300
+ if (existing.length > 0) {
301
+ var newQty = existing[0]['quantity'] + qty
302
+ query("UPDATE stock SET quantity = @newQty, last_updated = NOW() WHERE stock_id = @sid", { newQty: newQty, sid: existing[0]['stock_id'] })
303
+ } else {
304
+ insert('stock', {
305
+ warehouse_id: [warehouse_id],
306
+ product_id: productId,
307
+ quantity: qty
308
+ })
309
+ }
310
+ }
311
+
312
+ [status] = 'Received'
313
+ info('Received ' + poLines.length + ' line(s) from PO ' + [po_number])
314
+ ```
315
+
316
+ ## Execution modes
317
+
318
+ Actions run in one of two modes, declared in `ui/actions.json`:
319
+
320
+ | Mode | Description | `[field]` refers to |
321
+ |---|---|---|
322
+ | `"single"` (default) | Action runs once per selected record (server loops over the keys) | The current record |
323
+ | `"each"` | Synonym for `"single"` — identical runtime behavior; kept for readability when intent is "do this for each selected row" | The current record |
324
+ | `"batch"` | Action runs once with all records exposed as `__records`; iterate explicitly with `for x in records { ... }` | No bare `[field]` — use `x[field]` inside the loop |
325
+
326
+ In `batch` mode, access individual records with `__records.get(i)`, the count with `__records.count()`, and IDs via `__records.ids`.
327
+
328
+ ## Async actions
329
+
330
+ For long-running actions, set `"isAsync": true` in `ui/actions.json`. The action runs in the background via Hangfire, and the user gets a completion notification via SSE. The DSL syntax is the same.
331
+
332
+ ## Registering the action
333
+
334
+ In `ui/actions.json`:
335
+
336
+ ```json
337
+ {
338
+ "mark_done": {
339
+ "label": "Mark Done",
340
+ "description": "Mark this item as completed",
341
+ "icon": "bi-check-circle",
342
+ "entityCode": "todo_item",
343
+ "executionMode": "single",
344
+ "script": "mark_done",
345
+ "isTransacted": true,
346
+ "orderNum": 10
347
+ },
348
+ "create_quote_from_opp": {
349
+ "label": "Create Quote",
350
+ "description": "Create a draft quote from this opportunity's products",
351
+ "icon": "bi-file-earmark-plus",
352
+ "entityCode": "opportunity",
353
+ "executionMode": "single",
354
+ "script": "create_quote_from_opp",
355
+ "isTransacted": true,
356
+ "orderNum": 50
357
+ }
358
+ }
359
+ ```
360
+
361
+ **Action registration properties:**
362
+
363
+ | Property | Required | Description |
364
+ |---|---|---|
365
+ | `label` | Yes | Button label in the UI |
366
+ | `description` | Yes | Tooltip/description text |
367
+ | `icon` | Yes | Bootstrap icon **with** `bi-` prefix (e.g. `"bi-check-circle"`) — unlike menus which omit the prefix |
368
+ | `entityCode` | Yes | Which entity this action applies to |
369
+ | `executionMode` | Yes | `"single"` (default) or `"each"` — runs per record, DSL uses `[field]`; or `"batch"` — runs once, DSL uses `for x in records { ... }` with `__records` |
370
+ | `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
371
+ | `isTransacted` | Recommended | `true` = all changes roll back on error; `false` = partial commits possible |
372
+ | `orderNum` | Recommended | Display order in the action menu |
373
+ | `isAsync` | Optional | `true` = runs in background via Hangfire, user gets completion notification |
374
+
375
+ ## SQL in query() — important notes
376
+
377
+ 1. **Table names are schema-qualified** within the module: `crm.contact`, `wms.stock_movement`, `fin.invoice`. Use the module code as the schema prefix.
378
+ 2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
379
+ 3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
380
+ 4. **`insert()` is preferred over `query('INSERT ...')`** for inserting into module entities — it handles auto-fill (PKs, audit, number sequences). Use raw `query('INSERT ...')` only for cross-module or system tables.
381
+
382
+ ## Common mistakes
383
+
384
+ - Writing `params.note` instead of `params[note]` — **wrong**. Use bracket syntax for params.
385
+ - Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
386
+ - Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
387
+ - Writing `insert('entity', field1, value1)` (positional args) — **wrong**. Use `insert('entity', { field: value })` (object).
388
+ - Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders.
389
+ - Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
390
+ - Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
391
+ - Calling `TODAY()` or `NOW()` (uppercase) inside `execute:` — **wrong**, they're undefined there and install fails with `'TODAY' is not defined`. Use lowercase **`now()`** in `execute:`; `TODAY()`/`NOW()` are formula-only (`canExecute:`, formula columns).
392
+ - Forgetting that `insert()` returns the full row — you can use `quote.quote_id` immediately after insert.
393
+ - Using `[field]` inside a `query()` SQL string — **wrong**. `[field]` is resolved in JS scope, not inside SQL strings. Pass values as `@param` arguments.
394
+
395
+ ## Internal API (advanced)
396
+
397
+ The platform globals (`insert()`, `query()`, `error()`, etc.) are sugar on top of an internal API: `__ctx.insert()`, `__ctx.query()`, `__ctx.error()`, `__r.get('field')`, `__r.set('field', value)`. Both forms work in DSL scripts. The bare form (without `__ctx.` / `__r.`) is preferred for readability.