@dforge-core/dforge-mcp 0.2.0 → 0.2.2
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 +187 -0
- package/dist/server.js +355 -320
- package/package.json +46 -46
- package/resources/docs/conventions.md +12 -9
- package/resources/docs/dsl-reference.md +48 -12
- package/resources/schemas/domains.schema.json +78 -0
- package/resources/schemas/entity.schema.json +20 -2
- package/resources/schemas/triggers.schema.json +6 -6
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +1 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +70 -3
- package/skills/dforge-mcp-author/references/column-types.md +11 -4
- package/skills/dforge-mcp-author/references/conventions.md +4 -2
- package/skills/dforge-mcp-author/references/data-views.md +2 -2
- package/skills/dforge-mcp-author/references/field-types.md +1 -1
- package/skills/dforge-mcp-author/references/flags.md +37 -6
- package/skills/dforge-mcp-author/references/formulas.md +22 -4
- package/skills/dforge-mcp-author/references/schema-import.md +4 -2
- package/skills/dforge-mcp-author/references/validation-checklist.md +3 -2
- package/skills/dforge-module-build/SKILL.md +12 -5
package/package.json
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
2
|
+
"name": "@dforge-core/dforge-mcp",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/iash44/dForge-core",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dforge-core/dforge-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"dforge-mcp": "dist/server.js",
|
|
13
|
+
"dforge-install-skills": "scripts/install-skills.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/",
|
|
17
|
+
"docs/",
|
|
18
|
+
"resources/",
|
|
19
|
+
"skills/",
|
|
20
|
+
"scripts/install-skills.mjs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"CHANGELOG.md"
|
|
23
|
+
],
|
|
24
|
+
"main": "dist/server.js",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"sync-schemas": "node scripts/vendor-schemas.cjs",
|
|
31
|
+
"prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"install-skills": "node scripts/install-skills.mjs"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@dforge-core/dforge-cli": "^0.2.10",
|
|
38
|
+
"@dforge-core/metadata": "^0.0.12",
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
+
"zod": "^4.4.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.20.1",
|
|
44
|
+
"tsup": "^8.5.1",
|
|
45
|
+
"typescript": "^6.0.3",
|
|
46
|
+
"vitest": "^3.2.7"
|
|
47
|
+
}
|
|
48
48
|
}
|
|
@@ -135,15 +135,14 @@ The entity file uses the same `fields` schema as regular entities, plus an `exte
|
|
|
135
135
|
"fields": {
|
|
136
136
|
"customer_id": {
|
|
137
137
|
"dbDatatype": "int8",
|
|
138
|
-
"
|
|
139
|
-
"flags": "EM",
|
|
138
|
+
"flags": "E",
|
|
140
139
|
"orderNum": 34,
|
|
141
140
|
"description": "Customer FK"
|
|
142
141
|
},
|
|
143
142
|
"customer": {
|
|
144
143
|
"columnType": "R",
|
|
145
144
|
"fieldTypeCd": "lookup",
|
|
146
|
-
"flags": "
|
|
145
|
+
"flags": "VE",
|
|
147
146
|
"orderNum": 35,
|
|
148
147
|
"description": "Customer",
|
|
149
148
|
"link": {
|
|
@@ -641,7 +640,6 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
|
|
|
641
640
|
},
|
|
642
641
|
"department_id": {
|
|
643
642
|
"dbDatatype": "cuid",
|
|
644
|
-
"fieldTypeCd": "hidden",
|
|
645
643
|
"flags": "EM",
|
|
646
644
|
"orderNum": 30,
|
|
647
645
|
"description": "Department ID"
|
|
@@ -715,8 +713,7 @@ For every foreign key relationship, create TWO columns:
|
|
|
715
713
|
```json
|
|
716
714
|
"department_id": {
|
|
717
715
|
"dbDatatype": "cuid",
|
|
718
|
-
"
|
|
719
|
-
"flags": "EM",
|
|
716
|
+
"flags": "E",
|
|
720
717
|
"orderNum": 30,
|
|
721
718
|
"description": "Department ID"
|
|
722
719
|
}
|
|
@@ -727,7 +724,7 @@ For every foreign key relationship, create TWO columns:
|
|
|
727
724
|
"department": {
|
|
728
725
|
"columnType": "R",
|
|
729
726
|
"fieldTypeCd": "lookup",
|
|
730
|
-
"flags": "
|
|
727
|
+
"flags": "VE",
|
|
731
728
|
"orderNum": 35,
|
|
732
729
|
"description": "Department",
|
|
733
730
|
"link": {
|
|
@@ -752,8 +749,14 @@ For every foreign key relationship, create TWO columns:
|
|
|
752
749
|
- Reference columns use `"link"` (not `"params"`) for entity binding
|
|
753
750
|
- `link.entity` = target entity code, `link.thisKey` = local FK field, `link.otherKey` = remote PK field
|
|
754
751
|
- `params` is used for other purposes (e.g., dropdown `options`)
|
|
755
|
-
- Hidden FK column: `flags: "
|
|
756
|
-
|
|
752
|
+
- Hidden FK column: `flags: "E"` (no `V` = hidden from UI), `flags: "EM"` when the
|
|
753
|
+
relationship is genuinely required. `M` resolves to `isNullable: false` at install, so it
|
|
754
|
+
makes the FK `NOT NULL`; omitting both leaves the column nullable, which is the default.
|
|
755
|
+
Declaring `M` alongside `"isNullable": true` fails at pack time.
|
|
756
|
+
- Visible reference column: `columnType: "R"`, `flags: "VE"` — or `"VEM"` when the FK is
|
|
757
|
+
required. **Keep the two in step.** `M` is inert on a virtual column (a Reference is
|
|
758
|
+
required exactly when its hidden FK is), so `VEM` over an optional FK changes nothing but
|
|
759
|
+
reads as a required field to the next author.
|
|
757
760
|
- The FK column's `dbDatatype` **MUST match the referenced PK's type** — use `cuid` for `identity`-trait PKs (`cuid` is physically `int8`, **not** a UUID). A mismatch (e.g. FK `uuid` → PK `cuid`) fails install with *"foreign key constraint … cannot be implemented"*.
|
|
758
761
|
|
|
759
762
|
---
|
|
@@ -90,7 +90,8 @@ execute:
|
|
|
90
90
|
### Current record (single / each mode)
|
|
91
91
|
|
|
92
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
|
|
93
|
+
- `[refField].[targetField]` — navigate via a Reference (FK) column to a field on the linked entity. `[refField].targetField` (bare target) is accepted as an equivalent spelling. **Single-hop only** — `[a].[b].[c]` is a compile error; use `getRecord()` / `select()` to reach past one reference
|
|
94
|
+
- `old[fieldName]` — the field's value *before* the change, in an action invoked by an event trigger. Read-only; reads `null` on `insert` events and outside triggers
|
|
94
95
|
|
|
95
96
|
```dsl
|
|
96
97
|
# Read
|
|
@@ -148,9 +149,18 @@ var open = query(
|
|
|
148
149
|
{ s: 'open', cid: [customer_id] }
|
|
149
150
|
)
|
|
150
151
|
if (open.length == 0) { exit('Nothing open', 'info') }
|
|
151
|
-
|
|
152
|
+
for (var i = 0; i < open.length; i++) {
|
|
153
|
+
update('fin.invoice', open[i].invoice_id, { status: 'reminded' })
|
|
154
|
+
}
|
|
155
|
+
info('Reminded ' + open.length + ' open invoice(s)')
|
|
152
156
|
```
|
|
153
157
|
|
|
158
|
+
> **Pooled connection.** The connection is handed to another request once your
|
|
159
|
+
> statement finishes — so don't leave state behind on it: `SET LOCAL` not `SET`,
|
|
160
|
+
> `pg_advisory_xact_lock()` not `pg_advisory_lock()`, `ON COMMIT DROP` on temp
|
|
161
|
+
> tables. Don't assume the whole action is one transaction either — that depends
|
|
162
|
+
> on the execution path. Applies to `query()` only.
|
|
163
|
+
|
|
154
164
|
#### `insert(entityCd, fields) → record`
|
|
155
165
|
Insert a row. Returns the inserted record as a dictionary (PKs populated, defaults applied, audit columns set).
|
|
156
166
|
|
|
@@ -221,7 +231,8 @@ The result is a **read-only** snapshot — writing to it (`rec.set(...)`) throws
|
|
|
221
231
|
|
|
222
232
|
```dsl
|
|
223
233
|
var rec = getRecord('crm.customer', [customer_id])
|
|
224
|
-
|
|
234
|
+
if (rec.credit_limit == null) { error('Customer has no credit limit set') }
|
|
235
|
+
[customer_name] = rec.name
|
|
225
236
|
```
|
|
226
237
|
|
|
227
238
|
#### `getRecordOrNull(entityCd, key) → record | null`
|
|
@@ -238,7 +249,7 @@ Call a stored procedure with named args. Returns the proc's result row (or rows
|
|
|
238
249
|
|
|
239
250
|
```dsl
|
|
240
251
|
var summary = callProc('fin.compute_balance', { account_id: [account_id], as_of: now() })
|
|
241
|
-
|
|
252
|
+
[closing_balance] = summary.balance
|
|
242
253
|
```
|
|
243
254
|
|
|
244
255
|
#### `preloadRef(fkField)`
|
|
@@ -282,6 +293,8 @@ Fractional-unit arithmetic.
|
|
|
282
293
|
#### `info(message, opts?)`, `warn(message, opts?)`
|
|
283
294
|
Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
|
|
284
295
|
|
|
296
|
+
A message is a **receipt for work the action did**, not a way to publish a computed value. An action whose only output is `info('<some computed number>')` stores nothing — it can't be audited, re-read or reported on, and the value arrives unformatted. Build a report, a formula column, or an action that writes a result record instead. See `dforge://reference/action-dsl`, *When an action is the wrong tool*.
|
|
297
|
+
|
|
285
298
|
```dsl
|
|
286
299
|
info('Created PO ' + po.po_number, {
|
|
287
300
|
links: [{ entity: 'purchase_order', id: po.purchase_order_id }]
|
|
@@ -312,7 +325,10 @@ sendEmail([customer_id].[email], 'order_confirmation', {
|
|
|
312
325
|
```
|
|
313
326
|
|
|
314
327
|
#### `error(message)`
|
|
315
|
-
**Throw** — aborts execution
|
|
328
|
+
**Throw** — aborts execution immediately. Use for validation failures. Whether
|
|
329
|
+
prior `insert()` / writes in this `execute:` block are rolled back **depends on the
|
|
330
|
+
execution path** and is not something to rely on — validate before writing
|
|
331
|
+
anything (see the anti-pattern near the end of this document).
|
|
316
332
|
|
|
317
333
|
```dsl
|
|
318
334
|
if ([rating] == null || [rating] < 1 || [rating] > 5) {
|
|
@@ -448,12 +464,21 @@ if (det != null) { var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url')
|
|
|
448
464
|
#### `entityLink(entityCd, record, description?) → link value`
|
|
449
465
|
Build a clickable link to any entity record for storing in an `entitylink` (jsonb)
|
|
450
466
|
column. `record` is a row object (e.g. from `insert()` / `getRecord()`); its PK
|
|
451
|
-
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact.
|
|
452
|
-
|
|
467
|
+
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact.
|
|
468
|
+
|
|
469
|
+
The target entity is resolved at build time and stored **by id**, not by code:
|
|
470
|
+
`entity_cd` is only unique per module, so a bare `'invoice'` would re-resolve at click
|
|
471
|
+
time against the *reader's* folder module and could land on another module's entity.
|
|
472
|
+
Always qualify the code you pass (`'fin.invoice'`).
|
|
473
|
+
|
|
474
|
+
`description` is optional — omit it and the link is captioned with the entity's
|
|
475
|
+
`toString` evaluated against `record`, the way records are captioned everywhere else.
|
|
476
|
+
An explicit `description` still wins.
|
|
453
477
|
|
|
454
478
|
```dsl
|
|
455
479
|
var task = insert('workspace.task', { title: 'Follow up' })
|
|
456
|
-
|
|
480
|
+
# Captioned by workspace.task's toString — no third argument needed.
|
|
481
|
+
insert('workspace.task_note', { link: entityLink('workspace.task', task) })
|
|
457
482
|
```
|
|
458
483
|
|
|
459
484
|
#### `flush()`
|
|
@@ -766,17 +791,28 @@ execute:
|
|
|
766
791
|
[last_touched] = now()
|
|
767
792
|
```
|
|
768
793
|
|
|
769
|
-
### ❌ Assuming `error()`
|
|
794
|
+
### ❌ Assuming you know what `error()` does to work done above
|
|
770
795
|
|
|
771
|
-
`error()`
|
|
796
|
+
`error()` is a hard abort — never use it to "log and continue". What happens to
|
|
797
|
+
writes made *before* it is **not something you can rely on**: it depends on the
|
|
798
|
+
execution path. The same script keeps the row on one path and discards it on
|
|
799
|
+
another, and the difference isn't visible in the action.
|
|
772
800
|
|
|
773
801
|
```dsl
|
|
774
|
-
# WRONG —
|
|
802
|
+
# WRONG — is this insert kept or discarded? Depends on the path.
|
|
775
803
|
execute:
|
|
776
|
-
insert('audit_log', { event: 'attempted_close', record_id: [id] }) #
|
|
804
|
+
insert('audit_log', { event: 'attempted_close', record_id: [id] }) # unreliable!
|
|
777
805
|
if ([balance_due] > 0) { error('Cannot close — balance owing') }
|
|
778
806
|
```
|
|
779
807
|
|
|
808
|
+
Whether an `error()` unwinds earlier writes turns on whether that path opened a
|
|
809
|
+
transaction — for a synchronous action, on whether the entity has `auditHistory`
|
|
810
|
+
**and** whether the mode is `single` (in `batch` mode nothing covers the script);
|
|
811
|
+
a background action has none at all and keeps everything written before the
|
|
812
|
+
failure. This is a platform defect rather than a contract — actions are intended
|
|
813
|
+
to be atomic — so don't design around it either way. Write the script so the
|
|
814
|
+
question never arises: validate first.
|
|
815
|
+
|
|
780
816
|
```dsl
|
|
781
817
|
# RIGHT — validate first, then perform side-effects
|
|
782
818
|
execute:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://dforge.dev/schemas/domains.schema.json",
|
|
4
|
+
"title": "dForge Column Domains",
|
|
5
|
+
"description": "domains.json — reusable, named semantic types. A domain bundles a base datatype, a control, sizing and a shared option list under one code; columns reference it via \"domain\": \"module_cd.domain_cd\" instead of restating the definition. Structural fields are materialized onto each column at install; options stay on the domain and resolve at metadata-load time, so a shared list is authored and translated exactly once.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": {
|
|
8
|
+
"$ref": "#/$defs/domain"
|
|
9
|
+
},
|
|
10
|
+
"propertyNames": {
|
|
11
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
|
12
|
+
"description": "Domain code. Must not contain '.', which is reserved for the qualified 'module_cd.domain_cd' form."
|
|
13
|
+
},
|
|
14
|
+
"$defs": {
|
|
15
|
+
"domain": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["baseDatatypeCd"],
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"description": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Human-readable label. Localizable via translations/<locale>.json under domains.<domain_cd>.label."
|
|
23
|
+
},
|
|
24
|
+
"baseDatatypeCd": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Base datatype the domain stands for (e.g. string, number, date, datetime, boolean). Required — it is what consuming columns' DDL is generated from."
|
|
27
|
+
},
|
|
28
|
+
"dbDatatype": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Physical Postgres type for consuming columns (e.g. varchar, int4, numeric). Omit to let it be inferred from fieldTypeCd."
|
|
31
|
+
},
|
|
32
|
+
"fieldTypeCd": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Control used to render/edit consuming columns (e.g. dropdown, radio, flags, text, number)."
|
|
35
|
+
},
|
|
36
|
+
"maxLen": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"minimum": 1,
|
|
39
|
+
"description": "Max length for string-backed domains."
|
|
40
|
+
},
|
|
41
|
+
"precision": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"description": "Decimal precision for numeric domains."
|
|
45
|
+
},
|
|
46
|
+
"params": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "Field-type config shared by every consuming column — most importantly the 'options' list for dropdown/radio/flags. Never copied onto columns: it resolves from the domain at load time, which is what lets its labels be translated once for all consumers.",
|
|
49
|
+
"properties": {
|
|
50
|
+
"options": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"description": "Option list. Each entry is either a bare string (value doubles as label) or an object. Per-locale label/icon/color overrides live in translations/<locale>.json under domains.<domain_cd>.options.<value>.",
|
|
53
|
+
"items": {
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{ "type": "string" },
|
|
56
|
+
{
|
|
57
|
+
"type": "object",
|
|
58
|
+
"required": ["value"],
|
|
59
|
+
"additionalProperties": false,
|
|
60
|
+
"properties": {
|
|
61
|
+
"value": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "The stored code. Never translatable — translations match on it."
|
|
64
|
+
},
|
|
65
|
+
"label": { "type": "string" },
|
|
66
|
+
"icon": { "type": "string" },
|
|
67
|
+
"color": { "type": "string" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
"flags": {
|
|
131
131
|
"type": "string",
|
|
132
132
|
"pattern": "^[VIEMH]*$",
|
|
133
|
-
"description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden"
|
|
133
|
+
"description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden. M is resolved into isNullable:false at install, so it makes the column NOT NULL; it is inert on virtual (R/S/F) and identity columns. Declaring M together with isNullable:true is rejected at pack time."
|
|
134
134
|
},
|
|
135
135
|
"isPk": {
|
|
136
136
|
"type": "boolean",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"isNullable": {
|
|
140
140
|
"type": "boolean",
|
|
141
|
-
"description": "Whether the column allows NULL values"
|
|
141
|
+
"description": "Whether the column allows NULL values. Omitted means true (nullable) — only an explicit false, or the M flag which resolves to it, makes the column NOT NULL. Adding it to a column that existing tenant rows leave empty aborts the upgrade."
|
|
142
142
|
},
|
|
143
143
|
"isIdentity": {
|
|
144
144
|
"type": "boolean",
|
|
@@ -209,11 +209,29 @@
|
|
|
209
209
|
"type": "boolean",
|
|
210
210
|
"description": "On a self-referencing reference column (R column whose link.entity is the owning entity), set true to ALLOW parent/child cycles. When absent the self-reference is treated as an acyclic hierarchy and DB-level guards reject loops. Ignored on non-self-referencing columns."
|
|
211
211
|
},
|
|
212
|
+
"domain": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "Reference to a reusable column domain, qualified as 'module_cd.domain_cd' (unqualified resolves within this module, then tenant-created domains). The domain supplies dbDatatype, baseDatatypeCd, fieldTypeCd, maxLen, precision and params; declaring any of those alongside 'domain' is rejected at install. The column keeps its own description, flags, orderNum, isNullable and isPk."
|
|
215
|
+
},
|
|
212
216
|
"refFilter": {
|
|
213
217
|
"description": "Filter criteria for reference lookups. Condition values support @[field] placeholders resolved against the current record at lookup time (e.g. {\"c\":\"channel\",\"o\":\"eq\",\"v\":\"@[channel]\"} narrows options to rows matching the record's own channel); a condition whose placeholder is unset is dropped rather than matching nothing."
|
|
214
218
|
}
|
|
215
219
|
},
|
|
216
220
|
"oneOf": [
|
|
221
|
+
{
|
|
222
|
+
"title": "Domain-backed column",
|
|
223
|
+
"description": "Physical column whose datatype, control, sizing and options all come from a reusable column domain. Requires 'domain' and must NOT restate what the domain owns.",
|
|
224
|
+
"required": ["domain"],
|
|
225
|
+
"properties": {
|
|
226
|
+
"link": false,
|
|
227
|
+
"dbDatatype": false,
|
|
228
|
+
"baseDatatypeCd": false,
|
|
229
|
+
"fieldTypeCd": false,
|
|
230
|
+
"maxLen": false,
|
|
231
|
+
"precision": false,
|
|
232
|
+
"params": false
|
|
233
|
+
}
|
|
234
|
+
},
|
|
217
235
|
{
|
|
218
236
|
"title": "Data column",
|
|
219
237
|
"description": "Physical DB column (columnType 'D' or unspecified). Requires 'dbDatatype'. Must NOT have 'link'.",
|
|
@@ -33,24 +33,24 @@
|
|
|
33
33
|
"event": {
|
|
34
34
|
"type": "string",
|
|
35
35
|
"enum": ["insert", "update", "delete", "status_change", "any"],
|
|
36
|
-
"description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete."
|
|
36
|
+
"description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete. Events are raised by the row change itself, whichever path wrote it \u2014 a user edit and an action's `[field] = value` both count \u2014 except for the DSL's insert()/update()/delete()/query() builtins writing OTHER records, which raise nothing."
|
|
37
37
|
},
|
|
38
38
|
"condition": {
|
|
39
39
|
"type": "string",
|
|
40
|
-
"description": "Optional formula expression — same shape as canExecute in action DSL. Uses `[field]` syntax to reference the affected record. Single-line, evaluates to boolean. If omitted, trigger fires for every matching event."
|
|
40
|
+
"description": "Optional formula expression — same shape as canExecute in action DSL. Uses `[field]` syntax to reference the affected record. Single-line, evaluates to boolean. If omitted, trigger fires for every matching event. Reference navigation (`[ref].[target]`) is REJECTED here: the event-time evaluator only reads columns of the changed row, so a nav path would resolve to null and the trigger would misfire. Navigate inside the action instead, or denormalise the value onto the watched entity."
|
|
41
41
|
},
|
|
42
42
|
"action": {
|
|
43
43
|
"type": "string",
|
|
44
|
-
"description": "Action code to invoke. The
|
|
44
|
+
"description": "Action code to invoke. The trigger binds the affected record to the action as RECORD CONTEXT, so the action reads its columns with `[field]` (and its PK with `[pk_column]`) exactly like a UI-invoked action — no lookup needed. The action's `entityCode` MUST equal this trigger's `entity`, and its `executionMode` must not be `batch`; both are enforced at pack time. Cross-module references use the qualified form 'module.action'."
|
|
45
45
|
},
|
|
46
46
|
"params": {
|
|
47
47
|
"type": "object",
|
|
48
|
-
"description": "Optional
|
|
48
|
+
"description": "Optional STATIC parameters passed to the action at invocation time — the literal values written here, nothing else. Nothing is injected at runtime: there is no `record_id` param. The affected record arrives as record context (`[field]`), and its pre-change values as `old[field]` on update/delete/status_change events."
|
|
49
49
|
},
|
|
50
50
|
"async": {
|
|
51
51
|
"type": "boolean",
|
|
52
|
-
"default":
|
|
53
|
-
"description": "When true, the action runs in the background — the triggering transaction commits before the action starts. Recommended for slow actions (emails, external API calls); background failures are logged, not surfaced to the caller. When false, the action runs synchronously inside the same transaction as the triggering insert/update/delete: if the action fails (e.g. calls error()), the whole mutation rolls back and the error is returned to the caller. Keep async:false actions fast
|
|
52
|
+
"default": true,
|
|
53
|
+
"description": "When true (the default), the action runs in the background — the triggering transaction commits before the action starts. Recommended for slow actions (emails, external API calls); background failures are logged, not surfaced to the caller. When false, the action runs synchronously inside the same transaction as the triggering insert/update/delete: if the action fails (e.g. calls error()), the whole mutation rolls back and the error is returned to the caller — so an async:false trigger can undo the user's write. Keep async:false actions fast; they run on the request's transaction."
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"additionalProperties": false
|
|
@@ -5,6 +5,26 @@ Actions are dForge's business logic. They are scripts with three declarative blo
|
|
|
5
5
|
Lives in: `logic/actions/<action_name>.dsl`
|
|
6
6
|
Registered in: `ui/actions.json`
|
|
7
7
|
|
|
8
|
+
## When an action is the wrong tool
|
|
9
|
+
|
|
10
|
+
An action exists to **change something**. Its message is a receipt for work that was done — not the output itself.
|
|
11
|
+
|
|
12
|
+
Do **not** write an action whose `execute:` block only reads data, computes a value and reports it with `info()`. The result is a line of text that is never stored: it can't be audited, referenced, re-read or reported on, and every reader has to re-run the action to see it again. The value also reaches the user unformatted — a raw `query()` result carries no column metadata, so money renders as `1000.0099999999999` instead of `1,000.01`.
|
|
13
|
+
|
|
14
|
+
If you are about to write a `check_…`, `calculate_…` or `show_…` action, build one of these instead:
|
|
15
|
+
|
|
16
|
+
| What's actually needed | Build this |
|
|
17
|
+
|---|---|
|
|
18
|
+
| The user needs to **see** current numbers | A **report** or a data view — both are metadata-driven, so values format correctly, and the user can return to them any time without re-running anything. |
|
|
19
|
+
| A derived value **belongs to the record** | A **formula column** (`columnType: "F"`) or a roll-up — computed on read, visible on the card and in grids, filterable and sortable. |
|
|
20
|
+
| The **verdict itself matters** (approvals, credit checks, compliance) | An action that **writes a result record** — e.g. `credit_check(customer, checked_at, limit, outstanding, projected, verdict, checked_by)`. Now it's auditable and reportable, and `info()` becomes a legitimate receipt with a link to the new row. |
|
|
21
|
+
| The check must **block** something | A **trigger** with `async: false`, or a `canExecute:` guard on the action being gated — enforcement, not narration. |
|
|
22
|
+
|
|
23
|
+
Messages stay correct for exactly two things, and both are short by nature:
|
|
24
|
+
|
|
25
|
+
- a **receipt** for a mutation that just happened — `info('Created PO ' + po.po_number, { links: [...] })`
|
|
26
|
+
- a **refusal** that prevented one — `error('Period is closed')`
|
|
27
|
+
|
|
8
28
|
## Structure
|
|
9
29
|
|
|
10
30
|
A DSL file has up to three blocks. All are optional (but `execute:` is needed to actually do something).
|
|
@@ -126,6 +146,17 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
126
146
|
> `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
|
|
127
147
|
> loop and mutate per id instead.
|
|
128
148
|
|
|
149
|
+
> **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
|
|
150
|
+
> write layer: by design they skip folder row-filters, closed-period checks and registry state, and
|
|
151
|
+
> they raise no trigger or webhook events either. Only the **acted-on record's own**
|
|
152
|
+
> `[field] = value` raises them — that one is indistinguishable from a user editing the same field,
|
|
153
|
+
> and fires the entity's `update` / `status_change` triggers exactly as the grid does.
|
|
154
|
+
>
|
|
155
|
+
> So a trigger on `b` will not see `update('b', …)` from an action bound to `a`. If `b` must react,
|
|
156
|
+
> put the trigger on `a` — the entity the action is bound to — or make the change through `b`'s own
|
|
157
|
+
> action. The symptom when you get this wrong is a trigger that simply never fires, with nothing in
|
|
158
|
+
> the log to say why.
|
|
159
|
+
|
|
129
160
|
### select() — structured multi-row reads
|
|
130
161
|
|
|
131
162
|
The read counterpart to `insert()`/`update()`/`delete()`. Entity codes are metadata-validated,
|
|
@@ -174,7 +205,7 @@ var queued = select('comm.message', {
|
|
|
174
205
|
|---|---|
|
|
175
206
|
| `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
|
|
176
207
|
| `warn('message')` | Show a warning but continue execution. |
|
|
177
|
-
| `info('message')` | Show an informational message. |
|
|
208
|
+
| `info('message')` | Show an informational message. A **receipt for work the action did** — not a way to publish a computed value; see *When an action is the wrong tool*. |
|
|
178
209
|
| `exit('message'?, 'level'?)` | **Stop the script early without error** — changes made so far are kept. Level `'info'` (default) or `'warn'` styles the message. This is the DSL's early-return: a bare `return` is not valid at the top level of `execute:`. |
|
|
179
210
|
| `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
|
|
180
211
|
|
|
@@ -203,7 +234,7 @@ var queued = select('comm.message', {
|
|
|
203
234
|
| `addDays(date, n)` | Date | Add `n` days to a date, e.g. `addDays(now(), 30)`. |
|
|
204
235
|
| `nextNumber('entity')` | String | Generate next value from a number sequence. Usually not needed — platform auto-fills on `insert()`. Use for pre-generating numbers. |
|
|
205
236
|
| `callProc('proc_name', { args })` | Result | Call a stored procedure. Args are passed as named parameters. |
|
|
206
|
-
| `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact.
|
|
237
|
+
| `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact. The entity is resolved at build time and stored **by id** — `entity_cd` is only unique per module, so always pass a qualified code (`'fin.invoice'`). Omit `description` and the link is captioned with the entity's `toString` against `record`; an explicit `description` wins. |
|
|
207
238
|
|
|
208
239
|
> **Dates: `now()` in `execute:`, `TODAY()`/`NOW()` in formulas.** The `execute:` block runs as
|
|
209
240
|
> JavaScript (Jint) and exposes **lowercase `now()`** only — `TODAY()` and `NOW()` (uppercase) are
|
|
@@ -464,14 +495,49 @@ In `ui/actions.json`:
|
|
|
464
495
|
| `entityCode` | Yes | Which entity this action applies to |
|
|
465
496
|
| `executionMode` | Yes | `"single"` (default) or `"each"` — runs per record, DSL uses `[field]`; or `"batch"` — runs once, DSL uses `for x in records { ... }` with `__records` |
|
|
466
497
|
| `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
|
|
467
|
-
| `isTransacted` | Recommended | `true` =
|
|
498
|
+
| `isTransacted` | Recommended | `true` = the first record that fails aborts the run; `false` = the failure is reported and the loop moves to the next record. It does **not** open a transaction — see *Don't assume your action is atomic* below |
|
|
468
499
|
| `orderNum` | Recommended | Display order in the action menu |
|
|
469
500
|
| `isAsync` | Optional | `true` = runs in background via Hangfire, user gets completion notification |
|
|
470
501
|
|
|
502
|
+
## Don't assume your action is atomic
|
|
503
|
+
|
|
504
|
+
An action is **not** reliably one transaction, and `isTransacted: true` does not
|
|
505
|
+
make it one — it only decides whether the first failing record aborts the run.
|
|
506
|
+
Whether the writes made before a failure survive depends on the execution path:
|
|
507
|
+
|
|
508
|
+
| path | mode | writes before the failure |
|
|
509
|
+
|---|---|---|
|
|
510
|
+
| Sync execute, entity **with** `auditHistory` | `single` | rolled back |
|
|
511
|
+
| Sync execute, entity **with** `auditHistory` | `batch` | **stay committed** |
|
|
512
|
+
| Sync execute, entity without it | either | **stay committed** |
|
|
513
|
+
| Scheduled job | — | rolled back |
|
|
514
|
+
| Background (`isAsync: true`) action | either | **stay committed** |
|
|
515
|
+
|
|
516
|
+
Both "rolled back" rows are a side effect of the audit machinery opening a
|
|
517
|
+
transaction the action then inherits — and in `batch` mode it opens only after the
|
|
518
|
+
script has finished, so it doesn't cover the script at all. Adding or removing
|
|
519
|
+
`auditHistory`, or switching between `single` and `batch`, changes whether a failed
|
|
520
|
+
action leaves partial writes behind, with no change to the action itself.
|
|
521
|
+
|
|
522
|
+
**This is a platform defect, not a contract** — actions are intended to be atomic,
|
|
523
|
+
and this table is what happens until that lands. So don't design around either
|
|
524
|
+
outcome, and don't reach for `auditHistory` as an atomicity switch.
|
|
525
|
+
|
|
526
|
+
**Write actions that don't depend on it:** do all validation up front and call
|
|
527
|
+
`error()` before any `insert()` / `update()`, so there is nothing to unwind either
|
|
528
|
+
way.
|
|
529
|
+
|
|
471
530
|
## SQL in query() — important notes
|
|
472
531
|
|
|
473
532
|
`query()` is the **escape hatch** — reach for it only when the structured built-ins can't express the read/write (aggregates, multi-hop joins, CTEs, `FOR UPDATE`). For row-shaped work prefer `select()` / `insert()` / `update()` / `delete()`: they validate entity codes against metadata, coerce values per column, and (for `insert`/`update`) maintain auto-fill and audit columns.
|
|
474
533
|
|
|
534
|
+
> **`query()` runs on a pooled, shared connection.** Once your statement — or the
|
|
535
|
+
> surrounding transaction, where there is one — finishes, that connection is
|
|
536
|
+
> handed to another request. So never leave state behind on it: use `SET LOCAL`
|
|
537
|
+
> rather than `SET`, `pg_advisory_xact_lock()` rather than `pg_advisory_lock()`,
|
|
538
|
+
> and `ON COMMIT DROP` on any temp table. Only `query()` can do any of this — the
|
|
539
|
+
> structured built-ins emit plain DML — and nothing checks it statically.
|
|
540
|
+
|
|
475
541
|
1. **Table names are schema-qualified** within the module: `crm.contact`, `wms.stock_movement`, `fin.invoice`. Use the module code as the schema prefix. (Module codes with a hyphen, e.g. `crm-fin`, cannot be schema-qualified in raw SQL — another reason to use the structured built-ins, which accept the qualified `module.entity` code.)
|
|
476
542
|
2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
|
|
477
543
|
3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
|
|
@@ -479,6 +545,7 @@ In `ui/actions.json`:
|
|
|
479
545
|
|
|
480
546
|
## Common mistakes
|
|
481
547
|
|
|
548
|
+
- Writing an action that only reads, computes and prints (`info('Balance: ' + total)`) — **wrong shape**, not just wrong formatting. Nothing is stored, so nobody can re-read the answer. Use a report, a formula column, or write a result record. → *When an action is the wrong tool* (top of this file)
|
|
482
549
|
- Writing `params.note` instead of `params[note]` — **wrong**. Use bracket syntax for params.
|
|
483
550
|
- Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
|
|
484
551
|
- Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
|
|
@@ -33,8 +33,14 @@ Most columns. They map 1:1 to a physical SQL column.
|
|
|
33
33
|
|
|
34
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
35
|
|
|
36
|
+
The example below is a **required** relation, so both halves carry `M`. For an optional
|
|
37
|
+
one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
|
|
38
|
+
at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
|
|
39
|
+
virtual Reference half, leaving it there over an optional FK reads as a required field to
|
|
40
|
+
the next author. `dforge_entity_reference_add({ required })` emits both halves in step.
|
|
41
|
+
|
|
36
42
|
```json
|
|
37
|
-
// Hidden FK column — physical, hidden
|
|
43
|
+
// Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
|
|
38
44
|
"account_id": {
|
|
39
45
|
"dbDatatype": "cuid",
|
|
40
46
|
"flags": "EM",
|
|
@@ -175,9 +181,10 @@ inside one aggregate must point at the **same** set column, and `COUNT(*)` is re
|
|
|
175
181
|
|
|
176
182
|
> ⛔ **Do not use a Formula (`F`) column for set aggregation.** `SUM([set].[field])` in an `F`
|
|
177
183
|
> column is **not supported** by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and
|
|
178
|
-
> nav resolution
|
|
179
|
-
> **empty** on the form, grid, and reports (no error). `F` is for same-row
|
|
180
|
-
> (`[qty] * [price]`) and
|
|
184
|
+
> nav resolution walks N:1 references only, never a 1:N set (however many hops it is given). The
|
|
185
|
+
> column silently renders **empty** on the form, grid, and reports (no error). `F` is for same-row
|
|
186
|
+
> expressions (`[qty] * [price]`) and N:1 reference navigation (`[account].[name]`, and multi-hop
|
|
187
|
+
> chains — see `dforge://reference/formulas`).
|
|
181
188
|
|
|
182
189
|
> ⛔ **The aggregated child column must be physical.** A `G` trigger reads the child's `OLD`/`NEW`
|
|
183
190
|
> physical values, so aggregating a **virtual** child column (`F`/`R`/`S`) fails at install with
|
|
@@ -64,8 +64,10 @@ Only include what your module actually uses — not every module needs `print_te
|
|
|
64
64
|
|
|
65
65
|
**Every reference to another entity is two columns**:
|
|
66
66
|
|
|
67
|
-
1. Hidden FK column with `flags: "EM"` and `dbDatatype` matching the target PK type
|
|
68
|
-
2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VEM"
|
|
67
|
+
1. Hidden FK column with `flags: "E"` — `"EM"` when the relation is genuinely required — and `dbDatatype` matching the target PK type
|
|
68
|
+
2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VE"` / `"VEM"` **matching the FK**, and a `link` object pointing at the target
|
|
69
|
+
|
|
70
|
+
`M` resolves to `isNullable: false` at install, so it is what makes the FK column `NOT NULL` — keep the two halves in step. `dforge_entity_reference_add({ required })` emits both correctly.
|
|
69
71
|
|
|
70
72
|
Plus the actual FK constraint declared in the entity's `references` block.
|
|
71
73
|
|