@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,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.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Column Types Reference
|
|
2
|
+
|
|
3
|
+
Every column has a `columnType` (optional for the default physical type). Seven values exist:
|
|
4
|
+
|
|
5
|
+
| `columnType` | Physical DB column? | Description |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| (omitted) or `"D"` | Yes | **Data column** — default. Maps to a real SQL column. |
|
|
8
|
+
| `"R"` | No | **Reference column** — virtual N:1 lookup. Paired with a hidden FK column (see FK+Reference pattern). |
|
|
9
|
+
| `"S"` | No | **Set column** — virtual 1:N backwards reference. Used for detail grids. |
|
|
10
|
+
| `"F"` | No | **Formula column** — virtual computed value. Evaluated by the formula engine. |
|
|
11
|
+
| `"A"` | Yes | **Accumulation register column** — stores posted state for accumulation registers. Advanced (accounting modules). |
|
|
12
|
+
| `"L"` | Yes | **Ledger register column** — stores posted state for double-entry bookkeeping. Advanced (accounting modules). |
|
|
13
|
+
| `"G"` | Yes | **Generated column** — DB-level computed aggregate (e.g. SUM over child set via trigger, or PostgreSQL `GENERATED ALWAYS AS`). The aggregated child column **must be physical** (a `D` column) — never a virtual `F`/`R`/`S` column. For a simple roll-up total, prefer `F` (see below). |
|
|
14
|
+
|
|
15
|
+
**For most module development, you'll only use D, R, S, and F.** Types A, L, and G are for advanced accounting/registry modules that use the `postable`, `accumulation`, or `ledger` traits.
|
|
16
|
+
|
|
17
|
+
## Data columns (`columnType` omitted or `"D"`)
|
|
18
|
+
|
|
19
|
+
Most columns. They map 1:1 to a physical SQL column.
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
"first_name": {
|
|
23
|
+
"dbDatatype": "varchar",
|
|
24
|
+
"fieldTypeCd": "text",
|
|
25
|
+
"flags": "VEM",
|
|
26
|
+
"maxLen": 100,
|
|
27
|
+
"orderNum": 20,
|
|
28
|
+
"description": "First Name"
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Reference columns (`columnType: "R"`)
|
|
33
|
+
|
|
34
|
+
Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column** that does hold the physical data. Together they form the **FK+Reference pattern**.
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
// Hidden FK column — physical, hidden
|
|
38
|
+
"account_id": {
|
|
39
|
+
"dbDatatype": "cuid",
|
|
40
|
+
"flags": "EM",
|
|
41
|
+
"orderNum": 90,
|
|
42
|
+
"description": "Account ID"
|
|
43
|
+
},
|
|
44
|
+
// Visible Reference column — virtual, shown as lookup picker
|
|
45
|
+
"account": {
|
|
46
|
+
"columnType": "R",
|
|
47
|
+
"fieldTypeCd": "lookup",
|
|
48
|
+
"flags": "VEM",
|
|
49
|
+
"orderNum": 95,
|
|
50
|
+
"description": "Account",
|
|
51
|
+
"link": {
|
|
52
|
+
"entity": "account",
|
|
53
|
+
"thisKey": "account_id",
|
|
54
|
+
"otherKey": "account_id"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Plus declare the FK constraint in the entity's `references` block:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
"references": {
|
|
63
|
+
"FK_Contact_Account": {
|
|
64
|
+
"from": { "field": "account_id" },
|
|
65
|
+
"to": { "entity": "account", "field": "account_id" }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
> **FK column `dbDatatype` must exactly match the referenced entity's PK `dbDatatype`.** Never guess.
|
|
71
|
+
> - Entities using the `identity` trait → PK is `dbDatatype: "cuid"` → FK column must also be `"cuid"`
|
|
72
|
+
> - Cross-module or legacy entities → call `dforge_module_inspect` on the referenced module and read the PK column's `dbDatatype` before declaring the FK
|
|
73
|
+
> - **`bigint`, `integer`, `int8` are wrong values for FK columns** — even though `cuid` is physically stored as int8, the platform type name is `cuid`, not `bigint`
|
|
74
|
+
|
|
75
|
+
**Why two columns?** The FK stores the actual value. The Reference column configures how the value is rendered in the UI (as a typeahead picker showing the target's `toString`). They must be kept in sync — column order in JSON doesn't matter but `orderNum` determines UI ordering.
|
|
76
|
+
|
|
77
|
+
## Set columns (`columnType: "S"`)
|
|
78
|
+
|
|
79
|
+
Virtual 1:N backwards reference. Shows a grid of related records on the parent's detail view.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
"activities": {
|
|
83
|
+
"columnType": "S",
|
|
84
|
+
"fieldTypeCd": "grid",
|
|
85
|
+
"flags": "VEM",
|
|
86
|
+
"orderNum": 150,
|
|
87
|
+
"description": "Activities",
|
|
88
|
+
"link": {
|
|
89
|
+
"entity": "activity",
|
|
90
|
+
"thisKey": "contact_id", // column on THIS entity (parent PK)
|
|
91
|
+
"otherKey": "contact_id" // column on the OTHER entity (FK back to parent)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`link.entity` is the child entity. `link.thisKey` is the column on the current entity (typically the PK). `link.otherKey` is the FK column on the child entity pointing back to the parent.
|
|
97
|
+
|
|
98
|
+
Set columns are **not** declared in the `references` block — the FK already lives on the child entity.
|
|
99
|
+
|
|
100
|
+
## Formula columns (`columnType: "F"`)
|
|
101
|
+
|
|
102
|
+
Virtual computed column. No physical storage. Value is calculated at runtime by the formula engine.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
"full_name": {
|
|
106
|
+
"columnType": "F",
|
|
107
|
+
"fieldTypeCd": "text",
|
|
108
|
+
"baseDatatypeCd": "string",
|
|
109
|
+
"flags": "V",
|
|
110
|
+
"orderNum": 25,
|
|
111
|
+
"formula": "[first_name] + ' ' + [last_name]",
|
|
112
|
+
"description": "Full Name"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Required fields for formula columns**:
|
|
117
|
+
|
|
118
|
+
- `columnType: "F"`
|
|
119
|
+
- `fieldTypeCd` — how to render it
|
|
120
|
+
- `baseDatatypeCd` — **required** on formula columns even though there's no physical column; used for type inference
|
|
121
|
+
- `flags: "V"` (usually — formula columns are almost always read-only)
|
|
122
|
+
- `formula` — the formula expression
|
|
123
|
+
- `orderNum`
|
|
124
|
+
- `description`
|
|
125
|
+
|
|
126
|
+
Do **not** include `dbDatatype` on formula columns (there's no physical storage).
|
|
127
|
+
|
|
128
|
+
See `formulas.md` for syntax.
|
|
129
|
+
|
|
130
|
+
## Roll-up totals over child rows — use `F`, not `G`
|
|
131
|
+
|
|
132
|
+
To total a child set on a header (line items → order total, movements → quantity on hand),
|
|
133
|
+
**use a Formula column (`"F"`) with `SUM([set].[field])`**. It is computed at query time, so it
|
|
134
|
+
can reference **any** child column — including the child's own formula columns.
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
// purchase_order.total_amount — query-time roll-up (installs cleanly)
|
|
138
|
+
"total_amount": {
|
|
139
|
+
"columnType": "F",
|
|
140
|
+
"fieldTypeCd": "currency",
|
|
141
|
+
"baseDatatypeCd": "number",
|
|
142
|
+
"flags": "V",
|
|
143
|
+
"orderNum": 90,
|
|
144
|
+
"formula": "SUM([lines].[line_total])", // line_total may itself be a formula column
|
|
145
|
+
"description": "Total Amount"
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> ⛔ **Never make a `G` (Generated) column aggregate a virtual `F` column.** A `G` aggregate is
|
|
150
|
+
> maintained by a database trigger that reads the child's `OLD`/`NEW` *physical* values. A formula
|
|
151
|
+
> (`F`) column has no physical storage, so the trigger references a column that doesn't exist and
|
|
152
|
+
> **install fails** with `db_error: column old.<field> does not exist`. If you need a stored
|
|
153
|
+
> (`G`) aggregate over a *derived* quantity (e.g. a signed movement quantity), make that child
|
|
154
|
+
> column **physical** first — either a plain `D` column written by the action logic, or a
|
|
155
|
+
> same-row `G`/`GENERATED ALWAYS AS` column with a `dbDatatype` — then aggregate that. For most
|
|
156
|
+
> modules a query-time `F` roll-up is the right choice; reserve `G` for high-volume stored aggregates.
|
|
157
|
+
|
|
158
|
+
## Quick reference: which columnType do I use?
|
|
159
|
+
|
|
160
|
+
- **Plain text/number/date/etc. field that stores a value** → omit `columnType` (default `"D"`)
|
|
161
|
+
- **"This contact belongs to one account"** → `"R"` (plus hidden FK)
|
|
162
|
+
- **"This account has many contacts"** → `"S"` on the account side
|
|
163
|
+
- **"Compute full_name from first_name + last_name"** → `"F"`
|
|
164
|
+
- **"User picker" (points to admin.user)** → omit `columnType`, use `fieldTypeCd: "user"` (writes user ID directly, no paired FK)
|
|
165
|
+
- **"Polymorphic link to any entity"** → omit `columnType`, use `fieldTypeCd: "entitylink"` (stored as JSON)
|
|
166
|
+
- **"Roll-up total over child rows"** (line items → header total, movements → quantity on hand) → `"F"` with `SUM([set].[field])` (query-time; see "Roll-up totals" above). Reserve `"G"` for high-volume *stored* aggregates, and only over a **physical** child column — never over an `F` column.
|
|
167
|
+
- **"Accumulation register state"** → `"A"` (advanced accounting — requires `postable` + `accumulation` traits)
|
|
168
|
+
- **"Double-entry ledger state"** → `"L"` (advanced accounting — requires `postable` + `ledger` traits)
|