@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +84 -27
  3. package/dist/server.js +2957 -616
  4. package/docs/creating-modules.md +11 -4
  5. package/package.json +11 -6
  6. package/resources/docs/conventions.md +22 -16
  7. package/resources/docs/dsl-reference.md +767 -0
  8. package/resources/schemas/entity.schema.json +8 -1
  9. package/resources/schemas/print-templates.schema.json +82 -0
  10. package/resources/schemas/reports.schema.json +4 -0
  11. package/resources/schemas/triggers.schema.json +59 -0
  12. package/skills/dforge-mcp-author/SKILL.md +269 -69
  13. package/skills/dforge-mcp-author/examples/matrix-budget/README.md +43 -0
  14. package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_category.json +24 -0
  15. package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_line.json +56 -0
  16. package/skills/dforge-mcp-author/examples/matrix-budget/manifest.json +30 -0
  17. package/skills/dforge-mcp-author/examples/matrix-budget/security/roles.json +9 -0
  18. package/skills/dforge-mcp-author/examples/matrix-budget/seed-data/01-categories.json +8 -0
  19. package/skills/dforge-mcp-author/examples/matrix-budget/ui/data_views.json +42 -0
  20. package/skills/dforge-mcp-author/examples/simple-todo/README.md +38 -0
  21. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +83 -0
  22. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_list.json +43 -0
  23. package/skills/dforge-mcp-author/examples/simple-todo/logic/actions/mark_done.dsl +6 -0
  24. package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +32 -0
  25. package/skills/dforge-mcp-author/examples/simple-todo/security/roles.json +10 -0
  26. package/skills/dforge-mcp-author/examples/simple-todo/seed-data/01-lists.json +17 -0
  27. package/skills/dforge-mcp-author/examples/simple-todo/ui/actions.json +11 -0
  28. package/skills/dforge-mcp-author/examples/simple-todo/ui/data_views.json +35 -0
  29. package/skills/dforge-mcp-author/examples/simple-todo/ui/menus.json +28 -0
  30. package/skills/dforge-mcp-author/references/action-dsl.md +397 -0
  31. package/skills/dforge-mcp-author/references/column-types.md +168 -0
  32. package/skills/dforge-mcp-author/references/conventions.md +177 -0
  33. package/skills/dforge-mcp-author/references/data-migration.md +270 -0
  34. package/skills/dforge-mcp-author/references/data-views.md +243 -0
  35. package/skills/dforge-mcp-author/references/excel-import.md +61 -0
  36. package/skills/dforge-mcp-author/references/field-types.md +144 -0
  37. package/skills/dforge-mcp-author/references/filters.md +326 -0
  38. package/skills/dforge-mcp-author/references/flags.md +73 -0
  39. package/skills/dforge-mcp-author/references/formulas.md +206 -0
  40. package/skills/dforge-mcp-author/references/jobs.md +149 -0
  41. package/skills/dforge-mcp-author/references/manifest.md +123 -0
  42. package/skills/dforge-mcp-author/references/menus.md +164 -0
  43. package/skills/dforge-mcp-author/references/number-sequences.md +117 -0
  44. package/skills/dforge-mcp-author/references/print-templates.md +159 -0
  45. package/skills/dforge-mcp-author/references/queries.md +312 -0
  46. package/skills/dforge-mcp-author/references/reports.md +398 -0
  47. package/skills/dforge-mcp-author/references/schema-import.md +331 -0
  48. package/skills/dforge-mcp-author/references/security.md +244 -0
  49. package/skills/dforge-mcp-author/references/settings.md +120 -0
  50. package/skills/dforge-mcp-author/references/traits.md +153 -0
  51. package/skills/dforge-mcp-author/references/translations.md +158 -0
  52. package/skills/dforge-mcp-author/references/validation-checklist.md +183 -0
  53. 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