@dforge-core/dforge-mcp 0.2.0 → 0.2.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/dist/server.js CHANGED
@@ -35900,16 +35900,18 @@ var triggerAddSchema = {
35900
35900
  event: external_exports.enum(["insert", "update", "delete", "status_change", "any"]).describe(
35901
35901
  "insert/update/delete are obvious. status_change fires only when status field changes value. 'any' fires for all insert/update/delete."
35902
35902
  ),
35903
- action: external_exports.string().describe("Action code to invoke. Cross-module dotted form ('module.action') supported."),
35903
+ action: external_exports.string().describe(
35904
+ "Action code to invoke. The trigger binds the affected record to the action as RECORD CONTEXT \u2014 the action reads it with [field] / [pk_column], and old[field] for pre-change values. The action's entityCode MUST match this trigger's entity, and its executionMode must not be 'batch' (both rejected at pack time). Cross-module dotted form ('module.action') supported."
35905
+ ),
35904
35906
  description: external_exports.string().optional(),
35905
35907
  condition: external_exports.string().optional().describe(
35906
- "Optional formula expression (same shape as canExecute) using `[field]` syntax. Single-line boolean. If omitted, trigger fires on every matching event."
35908
+ "Optional formula expression (same shape as canExecute) using `[field]` syntax. Single-line boolean. If omitted, trigger fires on every matching event. Reference navigation (`[ref].[target]`) is REJECTED here \u2014 the event-time evaluator only reads columns of the changed row. Navigate inside the action instead."
35907
35909
  ),
35908
35910
  params: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
35909
- "Static params merged into the action invocation alongside auto-injected record_id."
35911
+ "STATIC params only \u2014 the literal values given here. Nothing is injected at runtime; there is no record_id param. The affected record arrives as record context ([field]), its pre-change values as old[field]."
35910
35912
  ),
35911
- async: external_exports.boolean().default(false).describe(
35912
- "When true, the action runs in background (recommended for slow actions). When false, runs in the same transaction \u2014 action failure rolls back the original DB change."
35913
+ async: external_exports.boolean().default(true).describe(
35914
+ "When true (the platform default), the action runs in background \u2014 recommended for slow actions. When false, it runs in the caller's transaction, so a failing action ROLLS BACK the user's original write."
35913
35915
  )
35914
35916
  };
35915
35917
  function triggerAdd(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
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
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -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
@@ -151,6 +152,12 @@ if (open.length == 0) { exit('Nothing open', 'info') }
151
152
  info('Open invoices: ' + open.length + ', total $' + open[0].total)
152
153
  ```
153
154
 
155
+ > **Pooled connection.** The connection is handed to another request once your
156
+ > statement finishes — so don't leave state behind on it: `SET LOCAL` not `SET`,
157
+ > `pg_advisory_xact_lock()` not `pg_advisory_lock()`, `ON COMMIT DROP` on temp
158
+ > tables. Don't assume the whole action is one transaction either — that depends
159
+ > on the execution path. Applies to `query()` only.
160
+
154
161
  #### `insert(entityCd, fields) → record`
155
162
  Insert a row. Returns the inserted record as a dictionary (PKs populated, defaults applied, audit columns set).
156
163
 
@@ -312,7 +319,10 @@ sendEmail([customer_id].[email], 'order_confirmation', {
312
319
  ```
313
320
 
314
321
  #### `error(message)`
315
- **Throw** — aborts execution and rolls back the **entire transaction** including any prior `insert()` / writes in this `execute:` block. Use for validation failures.
322
+ **Throw** — aborts execution immediately. Use for validation failures. Whether
323
+ prior `insert()` / writes in this `execute:` block are rolled back **depends on the
324
+ execution path** and is not something to rely on — validate before writing
325
+ anything (see the anti-pattern near the end of this document).
316
326
 
317
327
  ```dsl
318
328
  if ([rating] == null || [rating] < 1 || [rating] > 5) {
@@ -766,17 +776,28 @@ execute:
766
776
  [last_touched] = now()
767
777
  ```
768
778
 
769
- ### ❌ Assuming `error()` skips work done above
779
+ ### ❌ Assuming you know what `error()` does to work done above
770
780
 
771
- `error()` rolls back the **entire transaction** including prior `insert()` calls. Don't use it to "log and continue" — it's a hard abort.
781
+ `error()` is a hard abort — never use it to "log and continue". What happens to
782
+ writes made *before* it is **not something you can rely on**: it depends on the
783
+ execution path. The same script keeps the row on one path and discards it on
784
+ another, and the difference isn't visible in the action.
772
785
 
773
786
  ```dsl
774
- # WRONG — the insert below is rolled back when error fires
787
+ # WRONG — is this insert kept or discarded? Depends on the path.
775
788
  execute:
776
- insert('audit_log', { event: 'attempted_close', record_id: [id] }) # rolled back!
789
+ insert('audit_log', { event: 'attempted_close', record_id: [id] }) # unreliable!
777
790
  if ([balance_due] > 0) { error('Cannot close — balance owing') }
778
791
  ```
779
792
 
793
+ Whether an `error()` unwinds earlier writes turns on whether that path opened a
794
+ transaction — for a synchronous action, on whether the entity has `auditHistory`
795
+ **and** whether the mode is `single` (in `batch` mode nothing covers the script);
796
+ a background action has none at all and keeps everything written before the
797
+ failure. This is a platform defect rather than a contract — actions are intended
798
+ to be atomic — so don't design around it either way. Write the script so the
799
+ question never arises: validate first.
800
+
780
801
  ```dsl
781
802
  # RIGHT — validate first, then perform side-effects
782
803
  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
+ }
@@ -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'.",
@@ -37,20 +37,20 @@
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 action must NOT use record-context (`[field]`) syntax in a way that requires the original triggering record — the trigger passes the record id via params. Cross-module form 'module.action' is supported."
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 static parameters passed to the action at invocation time. Merged with the runtime-injected `record_id` / `record_old` (if event uses old-record diff)."
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": false,
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 — they run on the request's transaction."
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
@@ -464,14 +464,49 @@ In `ui/actions.json`:
464
464
  | `entityCode` | Yes | Which entity this action applies to |
465
465
  | `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
466
  | `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
467
- | `isTransacted` | Recommended | `true` = all changes roll back on error; `false` = partial commits possible |
467
+ | `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
468
  | `orderNum` | Recommended | Display order in the action menu |
469
469
  | `isAsync` | Optional | `true` = runs in background via Hangfire, user gets completion notification |
470
470
 
471
+ ## Don't assume your action is atomic
472
+
473
+ An action is **not** reliably one transaction, and `isTransacted: true` does not
474
+ make it one — it only decides whether the first failing record aborts the run.
475
+ Whether the writes made before a failure survive depends on the execution path:
476
+
477
+ | path | mode | writes before the failure |
478
+ |---|---|---|
479
+ | Sync execute, entity **with** `auditHistory` | `single` | rolled back |
480
+ | Sync execute, entity **with** `auditHistory` | `batch` | **stay committed** |
481
+ | Sync execute, entity without it | either | **stay committed** |
482
+ | Scheduled job | — | rolled back |
483
+ | Background (`isAsync: true`) action | either | **stay committed** |
484
+
485
+ Both "rolled back" rows are a side effect of the audit machinery opening a
486
+ transaction the action then inherits — and in `batch` mode it opens only after the
487
+ script has finished, so it doesn't cover the script at all. Adding or removing
488
+ `auditHistory`, or switching between `single` and `batch`, changes whether a failed
489
+ action leaves partial writes behind, with no change to the action itself.
490
+
491
+ **This is a platform defect, not a contract** — actions are intended to be atomic,
492
+ and this table is what happens until that lands. So don't design around either
493
+ outcome, and don't reach for `auditHistory` as an atomicity switch.
494
+
495
+ **Write actions that don't depend on it:** do all validation up front and call
496
+ `error()` before any `insert()` / `update()`, so there is nothing to unwind either
497
+ way.
498
+
471
499
  ## SQL in query() — important notes
472
500
 
473
501
  `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
502
 
503
+ > **`query()` runs on a pooled, shared connection.** Once your statement — or the
504
+ > surrounding transaction, where there is one — finishes, that connection is
505
+ > handed to another request. So never leave state behind on it: use `SET LOCAL`
506
+ > rather than `SET`, `pg_advisory_xact_lock()` rather than `pg_advisory_lock()`,
507
+ > and `ON COMMIT DROP` on any temp table. Only `query()` can do any of this — the
508
+ > structured built-ins emit plain DML — and nothing checks it statically.
509
+
475
510
  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
511
  2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
477
512
  3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.