@dforge-core/dforge-mcp 0.2.30 → 0.2.32

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/package.json CHANGED
@@ -1,47 +1,49 @@
1
1
  {
2
- "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.30",
4
- "description": "MCP server for dForge module authoring. Exposes 35 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
- "dependencies": {
29
- "@dforge-core/dforge-cli": "^0.2.23",
30
- "@dforge-core/metadata": "^0.0.30",
31
- "@modelcontextprotocol/sdk": "^1.30.0",
32
- "zod": "^4.4.3"
33
- },
34
- "devDependencies": {
35
- "@types/node": "^26.2.0",
36
- "tsup": "^8.5.1",
37
- "typescript": "^6.0.3",
38
- "vitest": "^4.1.10"
39
- },
40
- "scripts": {
41
- "build": "tsup",
42
- "sync-schemas": "node scripts/vendor-schemas.cjs",
43
- "typecheck": "tsc --noEmit",
44
- "test": "vitest run",
45
- "install-skills": "node scripts/install-skills.mjs"
46
- }
47
- }
2
+ "name": "@dforge-core/dforge-mcp",
3
+ "version": "0.2.32",
4
+ "description": "MCP server for dForge module authoring. Exposes 35 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
+ "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dforge-core/dforge-mcp.git"
11
+ },
12
+ "bin": {
13
+ "dforge-mcp": "dist/server.js",
14
+ "dforge-install-skills": "scripts/install-skills.mjs"
15
+ },
16
+ "files": [
17
+ "dist/",
18
+ "docs/",
19
+ "resources/",
20
+ "skills/",
21
+ "scripts/install-skills.mjs",
22
+ "README.md",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "main": "dist/server.js",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "sync-schemas": "node scripts/vendor-schemas.cjs",
32
+ "prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run",
35
+ "install-skills": "node scripts/install-skills.mjs"
36
+ },
37
+ "dependencies": {
38
+ "@dforge-core/dforge-cli": "^0.2.25",
39
+ "@dforge-core/metadata": "^0.0.32",
40
+ "@modelcontextprotocol/sdk": "^1.30.0",
41
+ "zod": "^4.4.3"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.2.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "^6.0.3",
47
+ "vitest": "^4.1.10"
48
+ }
49
+ }
@@ -472,14 +472,16 @@ scalar; denormalise instead of navigating; put the rest in `execute:` behind
472
472
 
473
473
  ### Messaging & notifications
474
474
 
475
- #### `info(message, opts?)`, `warn(message, opts?)`
476
- Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
475
+ #### `info(message, values?, opts?)`, `warn(message, values?, opts?)`
476
+ Queue user-facing toast. `values` fills the message's `{name}` placeholders; `opts.links: [{ label, entity, record }]` makes `label` in the text a link to that record (`pk:` instead of `record:` takes a key value or object). A link label must be data — a record number — never a word, since it is found in the text after translation.
477
+
478
+ **Translatable.** The message text is looked up by its exact English wording in the module's `messages` translations (`dforge://reference/translations`), in the user's language; no entry shows the English. Put varying values in `{name}` placeholders, filled from `values` — never join them into the string, which leaves no fixed text to translate. `links` is not a placeholder name.
477
479
 
478
480
  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 **record report** (a report with an `entities` attachment: it lands on the same record toolbar, but shows the numbers *and* the rows behind them, re-read live on every open), a formula column, or an action that writes a result record instead. Do not compute in DSL what a report dataset already aggregates. See `dforge://reference/action-dsl`, *When an action is the wrong tool*, and `dforge://reference/reports`.
479
481
 
480
482
  ```dsl
481
- info('Created PO ' + po.po_number, {
482
- links: [{ entity: 'purchase_order', id: po.purchase_order_id }]
483
+ info('Created PO {po}', { po: po.po_number }, {
484
+ links: [{ label: po.po_number, entity: 'purchase_order', record: po }]
483
485
  })
484
486
  warn('Stock below reorder point — review supplier')
485
487
  ```
@@ -506,7 +508,7 @@ sendEmail([customer_id].[email], 'order_confirmation', {
506
508
  })
507
509
  ```
508
510
 
509
- #### `error(message)`
511
+ #### `error(message, values?)`
510
512
  **Throw** — aborts execution immediately. Use for validation failures. Whether
511
513
  prior `insert()` / writes in this `execute:` block are rolled back **depends on the
512
514
  execution path** and is not something to rely on — validate before writing
@@ -516,10 +518,13 @@ anything (see the anti-pattern near the end of this document).
516
518
  if ([rating] == null || [rating] < 1 || [rating] > 5) {
517
519
  error('Rating must be 1-5')
518
520
  }
521
+ if (available < params[quantity]) {
522
+ error('Only {n} left', { n: available })
523
+ }
519
524
  ```
520
525
 
521
- #### `exit(message?, level?)`
522
- Successful early exit — **commits** work done so far. `level`: `"info"` (default) | `"warning"` | `"danger"` | `"success"`.
526
+ #### `exit(message?, level?, values?)`
527
+ Successful early exit — **commits** work done so far. `level`: `"info"` (default) | `"warning"` | `"danger"` | `"success"`. `values` fills the message's placeholders, as for `info()`.
523
528
 
524
529
  ```dsl
525
530
  if ([status] == 'closed') { exit('Already closed — nothing to do', 'info') }
@@ -21,7 +21,7 @@
21
21
  "type": "object",
22
22
  "description": "Entity key → placement. A key is the entity code as the module's manifest names it, or `module.entity` for an entity of a dependency — the same form a field link's `entity` takes. A key no entity has yet is a planned entity, drawn as a name only. An empty object places the entity automatically.",
23
23
  "propertyNames": {
24
- "minLength": 1
24
+ "$ref": "#/$defs/entityKey"
25
25
  },
26
26
  "additionalProperties": {
27
27
  "$ref": "#/$defs/diagramEntity"
@@ -46,6 +46,11 @@
46
46
  },
47
47
  "additionalProperties": false,
48
48
  "$defs": {
49
+ "entityKey": {
50
+ "type": "string",
51
+ "pattern": "^([a-zA-Z_][a-zA-Z0-9_-]*\\.)*[a-zA-Z_][a-zA-Z0-9_-]*$",
52
+ "description": "An entity code, optionally qualified by its module's code: `entity`, `module.entity`. Each part is an identifier the installer accepts: `[a-zA-Z_][a-zA-Z0-9_-]*`."
53
+ },
49
54
  "diagramEntity": {
50
55
  "type": "object",
51
56
  "properties": {
@@ -77,13 +82,11 @@
77
82
  "required": ["from", "to"],
78
83
  "properties": {
79
84
  "from": {
80
- "type": "string",
81
- "minLength": 1,
85
+ "$ref": "#/$defs/entityKey",
82
86
  "description": "Entity key of the many side — the entity that will carry the foreign key."
83
87
  },
84
88
  "to": {
85
- "type": "string",
86
- "minLength": 1,
89
+ "$ref": "#/$defs/entityKey",
87
90
  "description": "Entity key of the one side — the entity referenced."
88
91
  },
89
92
  "label": {
@@ -42,6 +42,11 @@
42
42
  "type": "boolean",
43
43
  "description": "Records of this entity accept comments \u2014 the composer and thread appear on the record card, beside the audit history. Opt-in per entity; `comments` in the manifest sets a module-wide default that this overrides in both directions."
44
44
  },
45
+ "auditHistory": {
46
+ "type": "string",
47
+ "enum": ["basic", "fields", "full"],
48
+ "description": "Audit history level for this entity, overriding the manifest's `auditHistory`: 'basic' logs each insert/update/delete without field details; 'fields' adds old/new values of changed fields; 'full' also stores display values (reference and user names). Omit to disable."
49
+ },
45
50
  "traits": {
46
51
  "type": "array",
47
52
  "description": "Entity traits expanded during module install (authoring shortcut). Trait definitions loaded from DB (seeded by metadata module).",
@@ -364,6 +369,14 @@
364
369
  "serverDefault": {
365
370
  "$ref": "#/$defs/serverDefault"
366
371
  },
372
+ "currency": {
373
+ "type": "string",
374
+ "description": "currency field type: fixed ISO 4217 code (e.g. 'USD') the value is formatted in. With currencyColumn, the code used for a record whose currencyColumn is empty."
375
+ },
376
+ "currencyColumn": {
377
+ "type": "string",
378
+ "description": "currency field type: a scalar column of the same entity holding the ISO 4217 code per record, e.g. a 'currency' dropdown. Each value is formatted in its own record's currency; a total over rows in different currencies shows no symbol. A line reads its header's code through a formula column ([invoice].[currency]). Display only — nothing converts between currencies."
379
+ },
367
380
  "pattern": {
368
381
  "type": "string",
369
382
  "description": "Format rule for this column, as an ECMAScript regex source. Overrides the field type's own pattern (field_type.def_params.pattern, which is what makes email/phone/url reject a malformed value). Enforced in the browser and again on data.insert / data.update, so keep to constructs both engines read alike: [0-9] and \\d are the same there, \\s is normalized to JavaScript's set, and a pattern that will not compile is skipped by both rather than enforced by one."
@@ -388,7 +401,7 @@
388
401
  "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."
389
402
  },
390
403
  "refFilter": {
391
- "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."
404
+ "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), and @[ref].[field] nav placeholders that read through a Reference column, as a formula does (e.g. @[purchase_order].[supplier_id] on an order line narrows to the order's supplier, also in the New dialog of the order's Lines set). A condition whose placeholder is unset is dropped rather than matching nothing."
392
405
  }
393
406
  },
394
407
  "oneOf": [
@@ -104,8 +104,8 @@
104
104
  },
105
105
  "auditHistory": {
106
106
  "type": "string",
107
- "enum": ["none", "minimal", "full"],
108
- "description": "Default audit history mode for entities in this module ('full' captures every field change; 'minimal' captures only inserts/deletes; 'none' disables). Individual entities can override."
107
+ "enum": ["basic", "fields", "full"],
108
+ "description": "Default audit history level for entities in this module: 'basic' logs each insert/update/delete without field details; 'fields' adds old/new values of changed fields; 'full' also stores display values (reference and user names). Omit to disable. Individual entities can override."
109
109
  },
110
110
  "comments": {
111
111
  "type": "boolean",
@@ -56,6 +56,11 @@
56
56
  "formula": { "type": "string" },
57
57
  "orderNum": { "type": "integer" },
58
58
  "description": { "type": "string", "description": "Supports {entity}/{Entity} placeholders" },
59
+ "labels": {
60
+ "type": "object",
61
+ "description": "Translated labels keyed by culture code (e.g. de-DE). Applied to every entity that takes the field from this trait; the module's translations/<locale>.json overrides them per entity. Supports {entity}/{Entity} placeholders.",
62
+ "additionalProperties": { "type": "string" }
63
+ },
59
64
  "maxLen": { "type": "integer" },
60
65
  "link": {
61
66
  "type": "object",
@@ -0,0 +1,267 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://dforge.dev/schemas/translations.schema.json",
4
+ "title": "dForge Translations",
5
+ "description": "A module's translations/<locale>.json, e.g. de-DE.json. English is the base text authored in the other module files, so an en-* file is not applied at install. Every key is optional: a missing entry falls back to the base text. Labels are required only for the locales the manifest lists in supportedLocales (constraint messages and DSL messages are warned about, never required).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "entities": {
13
+ "type": "object",
14
+ "description": "Entity code → labels for the entity, its fields, card sections and constraint messages.",
15
+ "additionalProperties": {
16
+ "$ref": "#/$defs/entity"
17
+ }
18
+ },
19
+ "domains": {
20
+ "type": "object",
21
+ "description": "Column domain code (domains.json) → label and option labels, translated once for every column that uses the domain.",
22
+ "additionalProperties": {
23
+ "$ref": "#/$defs/withOptions"
24
+ }
25
+ },
26
+ "folders": {
27
+ "type": "object",
28
+ "description": "Folder code → label. A sub-folder is keyed by its own flat code, not its path.",
29
+ "additionalProperties": {
30
+ "$ref": "#/$defs/labelOnly"
31
+ }
32
+ },
33
+ "views": {
34
+ "type": "object",
35
+ "description": "Data view code → label.",
36
+ "additionalProperties": {
37
+ "$ref": "#/$defs/labelOnly"
38
+ }
39
+ },
40
+ "menus": {
41
+ "type": "object",
42
+ "description": "Menu code → label and item labels.",
43
+ "additionalProperties": {
44
+ "$ref": "#/$defs/menu"
45
+ }
46
+ },
47
+ "settings": {
48
+ "type": "object",
49
+ "description": "Setting code (settings.json) → label and description.",
50
+ "additionalProperties": {
51
+ "$ref": "#/$defs/labelDesc"
52
+ }
53
+ },
54
+ "roles": {
55
+ "type": "object",
56
+ "description": "Module-qualified role code (e.g. \"crm.admin\") → label.",
57
+ "additionalProperties": {
58
+ "$ref": "#/$defs/labelOnly"
59
+ }
60
+ },
61
+ "reports": {
62
+ "type": "object",
63
+ "description": "Report code → label, dataset captions and parameter labels.",
64
+ "additionalProperties": {
65
+ "$ref": "#/$defs/report"
66
+ }
67
+ },
68
+ "actions": {
69
+ "type": "object",
70
+ "description": "Action code (ui/actions.json) → label, tooltip and parameter labels.",
71
+ "additionalProperties": {
72
+ "$ref": "#/$defs/action"
73
+ }
74
+ },
75
+ "print_templates": {
76
+ "type": "object",
77
+ "description": "Print template code → label. Accepted, but not applied at install yet.",
78
+ "additionalProperties": {
79
+ "$ref": "#/$defs/labelOnly"
80
+ }
81
+ },
82
+ "messages": {
83
+ "type": "object",
84
+ "description": "Text of a DSL info() / warn() / error() / exit() call, exactly as written in the .dsl file → its translation. {name} placeholders are filled from the call's values and may be reordered. See docs/business-logic/message-localization.md.",
85
+ "propertyNames": {
86
+ "minLength": 1
87
+ },
88
+ "additionalProperties": {
89
+ "type": "string",
90
+ "minLength": 1
91
+ }
92
+ }
93
+ },
94
+ "$defs": {
95
+ "label": {
96
+ "type": "string",
97
+ "description": "Translated display label."
98
+ },
99
+ "labelOnly": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "label": {
104
+ "$ref": "#/$defs/label"
105
+ }
106
+ }
107
+ },
108
+ "labelDesc": {
109
+ "type": "object",
110
+ "additionalProperties": false,
111
+ "properties": {
112
+ "label": {
113
+ "$ref": "#/$defs/label"
114
+ },
115
+ "desc": {
116
+ "type": "string",
117
+ "description": "Translated description or tooltip."
118
+ }
119
+ }
120
+ },
121
+ "option": {
122
+ "description": "A translated option: the label alone, or the label with an icon and color override.",
123
+ "oneOf": [
124
+ {
125
+ "type": "string"
126
+ },
127
+ {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "properties": {
131
+ "label": {
132
+ "type": "string"
133
+ },
134
+ "icon": {
135
+ "type": "string"
136
+ },
137
+ "color": {
138
+ "type": "string"
139
+ }
140
+ }
141
+ }
142
+ ]
143
+ },
144
+ "options": {
145
+ "type": "object",
146
+ "description": "Option value (the stored code) → its translation.",
147
+ "additionalProperties": {
148
+ "$ref": "#/$defs/option"
149
+ }
150
+ },
151
+ "withOptions": {
152
+ "type": "object",
153
+ "additionalProperties": false,
154
+ "properties": {
155
+ "label": {
156
+ "$ref": "#/$defs/label"
157
+ },
158
+ "options": {
159
+ "$ref": "#/$defs/options"
160
+ }
161
+ }
162
+ },
163
+ "entity": {
164
+ "type": "object",
165
+ "additionalProperties": false,
166
+ "properties": {
167
+ "label": {
168
+ "$ref": "#/$defs/label"
169
+ },
170
+ "desc": {
171
+ "type": "string"
172
+ },
173
+ "fields": {
174
+ "type": "object",
175
+ "description": "Column code → label and, for dropdown / radio / flags columns, option labels.",
176
+ "additionalProperties": {
177
+ "$ref": "#/$defs/withOptions"
178
+ }
179
+ },
180
+ "columnGroups": {
181
+ "type": "object",
182
+ "description": "Card-layout section code → heading.",
183
+ "additionalProperties": {
184
+ "$ref": "#/$defs/labelOnly"
185
+ }
186
+ },
187
+ "constraints": {
188
+ "type": "object",
189
+ "description": "Check / unique constraint name → violation message.",
190
+ "additionalProperties": {
191
+ "type": "object",
192
+ "additionalProperties": false,
193
+ "properties": {
194
+ "message": {
195
+ "type": "string"
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ },
202
+ "menu": {
203
+ "type": "object",
204
+ "additionalProperties": false,
205
+ "properties": {
206
+ "label": {
207
+ "$ref": "#/$defs/label"
208
+ },
209
+ "items": {
210
+ "type": "object",
211
+ "description": "Menu item code → label. Flat even when ui/menus.json nests items under children: a group and its children are siblings here.",
212
+ "additionalProperties": {
213
+ "$ref": "#/$defs/labelOnly"
214
+ }
215
+ }
216
+ }
217
+ },
218
+ "params": {
219
+ "type": "object",
220
+ "description": "Parameter code → label and dropdown option labels.",
221
+ "additionalProperties": {
222
+ "$ref": "#/$defs/withOptions"
223
+ }
224
+ },
225
+ "report": {
226
+ "type": "object",
227
+ "additionalProperties": false,
228
+ "properties": {
229
+ "label": {
230
+ "$ref": "#/$defs/label"
231
+ },
232
+ "datasets": {
233
+ "type": "object",
234
+ "description": "Dataset code → caption.",
235
+ "additionalProperties": {
236
+ "type": "object",
237
+ "additionalProperties": false,
238
+ "properties": {
239
+ "caption": {
240
+ "type": "string"
241
+ }
242
+ }
243
+ }
244
+ },
245
+ "params": {
246
+ "$ref": "#/$defs/params"
247
+ }
248
+ }
249
+ },
250
+ "action": {
251
+ "type": "object",
252
+ "additionalProperties": false,
253
+ "properties": {
254
+ "label": {
255
+ "$ref": "#/$defs/label"
256
+ },
257
+ "desc": {
258
+ "type": "string",
259
+ "description": "Translated tooltip."
260
+ },
261
+ "params": {
262
+ "$ref": "#/$defs/params"
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
@@ -302,12 +302,19 @@ var queued = select('comm.message', {
302
302
 
303
303
  | Function | Description |
304
304
  |---|---|
305
- | `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
306
- | `warn('message')` | Show a warning but continue execution. |
307
- | `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*. |
308
- | `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:`. |
305
+ | `error('message', values?)` | **Abort** the action and show an error to the user. Rolls back all changes. |
306
+ | `warn('message', values?, opts?)` | Show a warning but continue execution. |
307
+ | `info('message', values?, opts?)` | 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*. |
308
+ | `exit('message'?, 'level'?, values?)` | **Stop the script early without error** — changes made so far are kept. Level `'info'` (default), `'warning'`, `'danger'` or `'success'` styles the message. This is the DSL's early-return: a bare `return` is not valid at the top level of `execute:`. |
309
309
  | `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
310
310
 
311
+ The message of `error` / `warn` / `info` / `exit` is translatable: it is looked up by its exact English text in the module's `messages` translations (see `dforge://reference/translations`). Put values in `{name}` placeholders filled from `values` — never join them into the string, which leaves no fixed text to translate. `opts` carries record links (`{ links: [...] }`), so `links` is not a placeholder name:
312
+
313
+ ```javascript
314
+ error('Insufficient stock. Available: {available}, requested: {requested}',
315
+ { available: availableQty, requested: params[quantity] })
316
+ ```
317
+
311
318
  ### Email
312
319
 
313
320
  | Function | Description |
@@ -246,14 +246,21 @@ In modules that use settings, formulas can reference setting values with `$[Sett
246
246
 
247
247
  ## Validation and CHECK constraints
248
248
 
249
- Check constraints use a subset of the formula grammar. The server parses them to AST and converts to SQL. Common patterns:
249
+ A check constraint's `expression` is **not** a formula. It is copied verbatim into a
250
+ PostgreSQL `CHECK (...)`, so write plain SQL over real column names — no `[brackets]`, and
251
+ `IS NULL` / `IS NOT NULL` instead of the formula engine's null-safe `= null` / `!= null`:
250
252
 
251
253
  ```
252
- [quantity] > 0
253
- [end_date] >= [start_date]
254
- [email] LIKE '%@%'
254
+ quantity > 0
255
+ end_date IS NULL OR end_date >= start_date
256
+ cancelled = false OR cancel_reason_id IS NOT NULL
255
257
  ```
256
258
 
259
+ `module validate` rejects both formula habits: `[field]` fails the install with
260
+ `syntax error at or near "["`, and `x != null` is never true in SQL, so the CHECK would
261
+ always pass. The client pre-validates only a subset of SQL (comparisons, `AND`/`OR`/`NOT`,
262
+ `IN`, `BETWEEN`, `IS [NOT] NULL`, arithmetic, regex `~`); the database enforces the rest.
263
+
257
264
  The constraint's `message` (the violation text shown to users) is **localizable** — add a per-locale override at `entities.<entityCd>.constraints.<constraintName>.message` in each `translations/<locale>.json`. The base `message` in the entity JSON is the fallback. See `translations.md` → "Constraint violation messages ARE translatable".
258
265
 
259
266
  ## Examples — full formula columns
@@ -85,6 +85,7 @@ Translation files mirror the module's content structure. Each translatable objec
85
85
  | `actions` | Action labels, descriptions, param labels **and param dropdown option labels** | Action codes (keys from `ui/actions.json`) → `{ label, desc, params: { param_cd: { label, options: { value: … } } } }` |
86
86
  | `reports` | Report dataset captions, param labels **and param dropdown option labels** | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label, options: { value: … } } } }` |
87
87
  | `entities.<e>.constraints` | Check/unique constraint violation messages (opt-in — warned, not enforced) | Constraint codes → `{ message }` under the owning entity |
88
+ | `messages` | DSL `info` / `warn` / `error` / `exit` texts (opt-in — warned, not enforced) | Exact English text → translated text |
88
89
  | `entities.<e>.fields.<f>.options` | Dropdown/radio/flags **option labels** (opt-in) | Option `value` → localized label (string) or `{ label, icon, color }` |
89
90
  | `domains` | Column domain labels **and** their shared option labels (opt-in) | Domain codes → `{ label, options: { value: … } }` |
90
91
 
@@ -174,6 +175,22 @@ A `dropdown` **parameter** carries per-value labels exactly like a column, and t
174
175
  - **This requires base labels in the DSL.** `options=bank_transfer,cash` stores bare codes, and the dialog shows `bank_transfer` in *every* locale including English until you write `options=[bank_transfer:Bank transfer, cash:Cash]` (or the JSON object form). See `action-dsl.md`.
175
176
  - **Prefer a domain.** If the param's choices are the same list a column uses — which they usually are, since the value normally ends up written to that column — declare the param as `domain <domainCd>` and skip this section entirely: it inherits the domain's translated options. Reach for per-param `options` only when the list belongs to that one parameter and matches no column.
176
177
 
178
+ ### Action messages ARE translatable (opt-in)
179
+
180
+ The text an action passes to `info()`, `warn()`, `error()` or `exit()` is the base text **and the key**. Translate it under `messages` in each non-English file, keyed by the exact English text; `{name}` placeholders are filled from the call's second argument (`exit()`: third, after the level) and may be reordered:
181
+
182
+ ```json
183
+ "messages": {
184
+ "Item processed successfully": "Товар успішно оброблено",
185
+ "Recount completed: {count} line(s) checked": "Перерахунок завершено: перевірено рядків — {count}"
186
+ }
187
+ ```
188
+
189
+ - Lookup is in the **action's own module** (a bridge module's action uses the bridge's `messages`), in the user's language; a queued action uses the language of the user who queued it, a scheduled job the workspace default. No entry → the English text. Needs platform 1.26.0.
190
+ - A message joined from text and values (`'Bill ' + [bill_number]`) cannot be translated — rewrite it with placeholders.
191
+ - A link label must be data (a record number), not a word: links are placed after translation.
192
+ - **Opt-in, never mandatory.** With `supportedLocales`, install and `dforge_module_validate` warn about untranslated and joined messages; both always warn about `messages` entries no script uses (an English wording change leaves one behind) and about placeholders with no value. `dforge_translation_sync` adds every fixed message text to the non-English files.
193
+
177
194
  ### Roles ARE translated — and completeness is enforced
178
195
 
179
196
  Role labels are **required and localized** (this changed — older docs said roles were skipped; they are not). `SecurityRegistrar` gives every role a `res_id`, so `TranslationRegistrar` wires the role label exactly like entities/views/menus: