@dforge-core/dforge-mcp 0.2.43 → 0.2.44

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 CHANGED
@@ -4,6 +4,22 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
+ ## 0.2.44 — 2026-10-21
8
+
9
+ **Schemas follow metadata 0.0.44.** `dforge://schema/translations` describes `storedProcedures`
10
+ (result-column headers of a stored procedure, shared by every report bound to it) and a report's
11
+ `desc`. `dforge://schema/reports` accepts `fieldTypeCd`, `format` and `baseDatatypeCd` on a dataset's
12
+ `columnsDef`. `dforge://schema/entity` accepts a trait declared in the module's own `traits.json`.
13
+
14
+ **DSL builtins.** `printPdf(entityCd, key, templateCd)` renders a print template to a PDF as the current
15
+ user; `sendEmail()` takes a fourth `attachments` argument (a `printPdf()` result or an array of them);
16
+ `downloadText()` / `downloadBase64()` hand the user a file the script built, in any code page.
17
+
18
+ **Needs `@dforge-core/dforge-cli` 0.2.35**, which compiles the above, requires a translation per
19
+ stored-procedure column on `dforge_module_pack` / `dforge_module_validate`, refuses a request filter
20
+ with an unknown operator instead of dropping it, and reads and generates every module file with LF
21
+ line endings on every platform.
22
+
7
23
  ## 0.2.43 — 2026-10-19
8
24
 
9
25
  **Schemas follow metadata 0.0.43.** `dforge://schema/registers` describes `analyticTotals` (an
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.33/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.35/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31290,7 +31290,7 @@ function buildZedTasks() {
31290
31290
  var fs = __toESM(require("fs"));
31291
31291
  var path = __toESM(require("path"));
31292
31292
 
31293
- // node_modules/.pnpm/@dforge-core+metadata@0.0.43/node_modules/@dforge-core/metadata/dist/index.js
31293
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/index.js
31294
31294
  var AggType = {
31295
31295
  Sum: "sum",
31296
31296
  Count: "count",
@@ -34622,7 +34622,7 @@ var path10 = __toESM(require("path"));
34622
34622
  var fs7 = __toESM(require("fs"));
34623
34623
  var path9 = __toESM(require("path"));
34624
34624
 
34625
- // node_modules/.pnpm/@dforge-core+metadata@0.0.43/node_modules/@dforge-core/metadata/dist/dsl/index.js
34625
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/dsl/index.js
34626
34626
  var PUNCT3 = ["===", "!=="];
34627
34627
  var PUNCT2 = [
34628
34628
  "==",
@@ -35474,9 +35474,9 @@ var BUILTINS = [
35474
35474
  },
35475
35475
  {
35476
35476
  name: "sendEmail",
35477
- signature: "sendEmail(to, subjectOrTemplate, dataOrBody?)",
35477
+ signature: "sendEmail(to, subjectOrTemplate, dataOrBody?, attachments?)",
35478
35478
  returns: "void",
35479
- doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. There is no fourth argument."
35479
+ doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL). Only a `printPdf()` result can be attached, and only by the user who generated it (or a workspace admin); at most 5 files and 10 MB in all per email (`Email:MaxAttachments`, `Email:MaxAttachmentBytes`). A file the sender may not attach fails the action. Not available in a scheduled job, whose emails with attachments are not queued."
35480
35480
  },
35481
35481
  // External calls
35482
35482
  {
@@ -35515,7 +35515,19 @@ var BUILTINS = [
35515
35515
  name: "download",
35516
35516
  signature: "download(url, fileName?)",
35517
35517
  returns: "void",
35518
- doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing \u2014 the URL must already serve the bytes, e.g. the `/api/temp-file/{token}` a service handed back. To put a remote file INTO storage, fetch it with `callApi()` and write it to a file column."
35518
+ doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing \u2014 the URL must already serve the bytes, e.g. the `/api/temp-file/{token}` a service handed back. To hand over a file the script builds, use `downloadText()` / `downloadBase64()`. To put a remote file INTO storage, fetch it with `callApi()` and write it to a file column."
35519
+ },
35520
+ {
35521
+ name: "downloadText",
35522
+ signature: "downloadText(content, fileName, encoding?, mimeType?)",
35523
+ returns: "string",
35524
+ doc: "Hand the user a text file the script built: encodes `content` in `encoding` (`utf-8` by default; `windows-1251`, `cp866`, `koi8-u`\u2026 also work), stores it in temp storage and sets it as the action's download. Line endings are written as given (join with `'\\r\\n'` for CR LF), no BOM. Returns the signed `/api/temp-file/{token}` URL, valid for an hour (`FileStorage:TempTokenExpirationMinutes`). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives the download, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run."
35525
+ },
35526
+ {
35527
+ name: "downloadBase64",
35528
+ signature: "downloadBase64(base64, fileName, mimeType?)",
35529
+ returns: "string",
35530
+ doc: "`downloadText()` for bytes: decodes `base64` and hands it to the user as `fileName` (`application/octet-stream` unless `mimeType` says otherwise). Returns the signed download URL."
35519
35531
  },
35520
35532
  {
35521
35533
  name: "getSetting",
@@ -35568,6 +35580,13 @@ var BUILTINS = [
35568
35580
  returns: "link",
35569
35581
  entityArg: 0,
35570
35582
  doc: "Build a clickable link to a record, for an `entitylink` column. Always pass a **qualified** code \u2014 `entity_cd` is only unique per module."
35583
+ },
35584
+ {
35585
+ name: "printPdf",
35586
+ signature: "printPdf(entityCd, key, templateCd)",
35587
+ returns: "object",
35588
+ entityArg: 0,
35589
+ doc: "Render a print template to a PDF **as the current user** and return the stored file as `{ fileId, fileName, mimeType, size, checksum }` \u2014 a descriptor, never the bytes. `key` is the record's `{ column: value }` key (or a scalar for a single-column key); `templateCd` is the template's code on that entity. The record is read under the user's rights and the folder's row filter, as the Generate PDF button reads it. Always pass a **qualified** entity code. Not available in a scheduled job or a background action (no signed-in user), refused in a computing-only run, and limited to 10 per execution; each render holds the action's transaction open while it works."
35571
35590
  }
35572
35591
  ];
35573
35592
  var BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.name, b]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
4
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
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -26,8 +26,8 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
- "@dforge-core/dforge-cli": "^0.2.33",
30
- "@dforge-core/metadata": "^0.0.43",
29
+ "@dforge-core/dforge-cli": "^0.2.35",
30
+ "@dforge-core/metadata": "^0.0.44",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^4.4.3"
33
33
  },
@@ -503,8 +503,8 @@ Send a notification to a specific user's inbox.
503
503
  notify([approver_id], 'New purchase request from ' + [requester_name] + ' awaiting approval')
504
504
  ```
505
505
 
506
- #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?)`
507
- Two modes:
506
+ #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?, attachments?)`
507
+ Two modes (a second argument with no spaces that contains `.` or `_` is read as a template code, so give a raw subject spaces). The optional fourth argument attaches files: a `printPdf()` result, or an array of them (a single file as itself, not `[a]`). Max 5 files and 10 MB; only the user who generated a PDF, or a workspace admin, can attach it; not in a scheduled job.
508
508
 
509
509
  ```dsl
510
510
  // Raw mode: (to, subject, body)
@@ -567,8 +567,25 @@ if (info.fileSize > 5 * 1024 * 1024) { error('Receipt larger than 5 MB') }
567
567
  if (info.mimeType != 'application/pdf') { error('Receipt must be PDF') }
568
568
  ```
569
569
 
570
+ #### `printPdf(entityCd, key, templateCd)`
571
+ Render a print template to a PDF as the current user and return the archived file as `{ fileId, fileName, mimeType, size, checksum }`; attach it with `sendEmail()`. Pass a qualified entity code. Not in a scheduled job or a background action; max 10 per run.
572
+
573
+ ```dsl
574
+ execute:
575
+ var pdf = printPdf('fin.invoice', { invoice_id: [invoice_id] }, 'invoice')
576
+ sendEmail([customer_id].[email], 'Your invoice ' + [number], '<p>Attached.</p>', pdf)
577
+ ```
578
+
579
+ #### `downloadText(content, fileName, encoding?, mimeType?)` / `downloadBase64(base64, fileName, mimeType?)`
580
+ Hand the user a file the script built. `encoding` defaults to `utf-8`; code pages such as `windows-1251` and `cp866` work; line endings are written as given, no BOM. Both return the signed download URL, which a background or scheduled run can pass on in `notify()`. One download per action (a later call replaces the earlier); max 10 MB per file, 10 files per run.
581
+
582
+ ```dsl
583
+ execute:
584
+ downloadText(csv, 'payments.txt', 'windows-1251')
585
+ ```
586
+
570
587
  #### `download(url, fileName?)`
571
- Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file.
588
+ Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file. It fetches nothing: the URL must already serve the bytes.
572
589
 
573
590
  ```dsl
574
591
  execute:
@@ -1180,7 +1197,8 @@ The `audit` trait auto-maintains `last_updated` on INSERT/UPDATE if the column e
1180
1197
  | Run SQL | `query('SELECT ... WHERE c = @v', { v: value })` |
1181
1198
  | Get setting | `getSetting('setting_cd')` |
1182
1199
  | Send toast | `info('msg')`, `warn('msg')` |
1183
- | Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)` |
1200
+ | Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)`; add a `printPdf()` result as the 4th argument to attach |
1201
+ | Give the user a built file | `downloadText(content, 'f.txt', 'windows-1251')` |
1184
1202
  | Send inbox notification | `notify(userId, 'msg')` |
1185
1203
  | Call a shared helper | `round2([amount])` from `logic/functions/*.dsl` |
1186
1204
  | Call another module's helper | `cdy_accounting.roleOf(...)`, imported in `deps/<module>.json` |
@@ -108,10 +108,20 @@
108
108
  },
109
109
  "traits": {
110
110
  "type": "array",
111
- "description": "Entity traits expanded during module install (authoring shortcut). Trait definitions loaded from DB (seeded by metadata module).",
111
+ "description": "Entity traits expanded during module install (authoring shortcut): a platform trait, or one the module declares in its own traits.json. Pack and install refuse a name that is neither; the schema cannot see traits.json, so it accepts any trait-shaped name and lists only the platform traits.",
112
112
  "items": {
113
- "type": "string",
114
- "enum": ["identity", "audit", "audit-full", "soft-delete", "sorting", "postable", "accumulation", "ledger", "period", "file-library"]
113
+ "anyOf": [
114
+ {
115
+ "type": "string",
116
+ "enum": ["identity", "audit", "audit-full", "soft-delete", "sorting", "postable", "accumulation", "ledger", "period", "file-library"],
117
+ "description": "Platform trait."
118
+ },
119
+ {
120
+ "type": "string",
121
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
122
+ "description": "A trait declared in the module's own traits.json."
123
+ }
124
+ ]
115
125
  },
116
126
  "uniqueItems": true
117
127
  },
@@ -401,9 +401,13 @@
401
401
 
402
402
  "columnDef": {
403
403
  "type": "object",
404
+ "description": "Override of one column the dataset already projects (from the entity, the stored procedure's result columns, or the register). Name only what differs: each key replaces the projected value. A label here is a literal and wins over a translated header.",
404
405
  "properties": {
405
- "label": { "type": "string" },
406
- "visible": { "type": "boolean" },
406
+ "label": { "type": "string", "description": "Column header. Not translated: leave it out to keep the entity's or the procedure's translated header." },
407
+ "fieldTypeCd": { "type": "string", "description": "Control the value renders with, replacing the projected one." },
408
+ "format": { "type": "string", "description": "Display format, replacing the column's display format." },
409
+ "baseDatatypeCd": { "type": "string", "description": "Read only where the dataset projects no columns of its own (a stored procedure with no result columns), when columnsDef is the column list. Ignored as an override: the projected column's datatype stands." },
410
+ "visible": { "type": "boolean", "description": "Accepted, but neither report.run nor the client reads it: it has no effect." },
407
411
  "width": { "type": "integer", "minimum": 1 }
408
412
  },
409
413
  "additionalProperties": false
@@ -84,6 +84,23 @@
84
84
  "$ref": "#/$defs/report"
85
85
  }
86
86
  },
87
+ "storedProcedures": {
88
+ "type": "object",
89
+ "description": "Stored-procedure code (logic/stored_procedures.json) → result-column headers. A header belongs to the procedure, not to a report: every dataset bound to the procedure shows the same translation. A dataset's columnsDef label overrides it with a literal, so leave the label out of columnsDef to keep the column translated. Required, per declared column, by `module pack` / `module validate` for each non-English supported locale; install only warns.",
90
+ "additionalProperties": {
91
+ "type": "object",
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "columns": {
95
+ "type": "object",
96
+ "description": "Result column code → header.",
97
+ "additionalProperties": {
98
+ "$ref": "#/$defs/labelOnly"
99
+ }
100
+ }
101
+ }
102
+ }
103
+ },
87
104
  "actions": {
88
105
  "type": "object",
89
106
  "description": "Action code (ui/actions.json) → label, tooltip and parameter labels.",
@@ -248,6 +265,10 @@
248
265
  "label": {
249
266
  "$ref": "#/$defs/label"
250
267
  },
268
+ "desc": {
269
+ "type": "string",
270
+ "description": "Translated report description."
271
+ },
251
272
  "datasets": {
252
273
  "type": "object",
253
274
  "description": "Dataset code → caption.",
@@ -249,6 +249,14 @@ var note = params[note] // no quotes around param name in this DSL
249
249
  > `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
250
250
  > loop and mutate per id instead.
251
251
 
252
+ > **Posting state.** `update()` setting a register posting state (the `A` / `L` column) posts or unposts
253
+ > the document through the register engine, in a trigger action and in a background run alike; a refused
254
+ > posting rolls the caller's write back. A background run holds no transaction, so such a call opens its
255
+ > own: atomic per call, not per run. A scheduled run cannot post with `update()`.
256
+
257
+ > **Keys of any type.** A field saved with `[field] = value` binds the record key by the key column's own
258
+ > type, so a `uuid` or a digits-only `varchar` key works.
259
+
252
260
  > **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
253
261
  > write layer: by design they skip folder row-filters, closed-period checks and registry state, and
254
262
  > they raise no trigger or webhook events either. Only the **acted-on record's own**
@@ -339,7 +347,16 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
339
347
  | Function | Description |
340
348
  |---|---|
341
349
  | `sendEmail(to, subject, htmlBody)` | Send a raw email. `to` is an email address string. |
342
- | `sendEmail(to, subject, htmlBody, templateCode)` | Send a templated email (if email templates are configured). |
350
+ | `sendEmail(to, templateCode, dataObj)` | Send a templated email. The **second** argument picks the mode: one with no spaces that contains `.` or `_` is read as a template code, so give a raw subject spaces. |
351
+ | `sendEmail(to, subjectOrTemplate, dataOrBody, attachments)` | The optional fourth argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference). Only a `printPdf()` result can be attached, and only by the user who generated it or a workspace admin. At most 5 files and 10 MB per email. A file the sender may not attach fails the action. A scheduled job's email with attachments fails and rolls back. |
352
+
353
+ ### PDF and file output
354
+
355
+ | Function | Returns | Description |
356
+ |---|---|---|
357
+ | `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }` | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
358
+ | `downloadText(content, fileName, encoding?, mimeType?)` | URL string | Hand the user a text file the script built. `encoding` is `utf-8` by default; `windows-1251`, `cp866`, `koi8-u` and other code pages work, and a character the encoding cannot represent is an error. Line endings are written as given (join with `'\r\n'` for CR LF), no BOM. Returns the signed download URL (valid an hour). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives it, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run. |
359
+ | `downloadBase64(base64, fileName, mimeType?)` | URL string | `downloadText()` for bytes; `application/octet-stream` unless `mimeType` says otherwise. |
343
360
 
344
361
  ### External API Calls
345
362
 
@@ -350,7 +367,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
350
367
  | `getFileUrl(fileFieldValue)` | String | Generate a temporary signed download URL (relative path) for a file field. Use in email templates or notifications — not for `callApi()`. |
351
368
  | `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
352
369
  | `getFileInfo(fileFieldValue)` | Object \| null | File metadata for a file field. |
353
- | `download(url, fileName?)` | — | Fetch a URL into tenant storage. |
370
+ | `download(url, fileName?)` | — | Set a download URL on the action result; the browser opens it when the action returns. It fetches and stores nothing: the URL must already serve the bytes. To hand over a file the script builds, use `downloadText()` / `downloadBase64()`. |
354
371
  | `callService('name', { args })` | Any | Invoke a registered host service. |
355
372
 
356
373
  ### Files, settings, document extraction
@@ -311,6 +311,7 @@ The user never sees a record unless it passes **all** layers. You don't need to
311
311
 
312
312
  - **`null` filter** (or omitted) means no filtering — show everything the user has access to.
313
313
  - **Single condition** is valid without a group wrapper.
314
+ - **An unknown operator is refused**, not dropped: a request filter (`data.get`, export, reports, a view's query, DSL `select()`) with an unknown operator, an unknown group combinator or an item that is neither condition nor group fails with `INVALID_REQUEST` naming the operator, its column and the valid set. `like` is not an operator: `mask` is SQL `LIKE`, `start` a case-insensitive prefix. A condition on a column the entity does not have is still dropped.
314
315
  - Use **short operators** (`=`, `!=`, `>`) or **enum keys** (`eq`, `notEq`, `gr`) — both work.
315
316
  - For folder `rowFilter` in module packages, prefer the short compact form: `{ "c": "...", "o": "eq", "v": ... }`.
316
317
  - Dates are ISO strings: `"2026-01-01"`.
@@ -320,6 +321,7 @@ The user never sees a record unless it passes **all** layers. You don't need to
320
321
 
321
322
  - Using the JSON filter format inside `query()` in DSL actions — **wrong**. `query()` uses raw SQL.
322
323
  - Wrapping a single condition in a group unnecessarily — **valid but noisy**. A bare condition is fine.
324
+ - Using `"o": "like"` — **wrong**, refused. Use `"o": "mask"` with `%`.
323
325
  - Using `"operator": "equals"` — **wrong**. Use `"o": "="` or `"o": "eq"`.
324
326
  - Using `"column": "status"` — **wrong**. Use `"c": "status"`.
325
327
  - Forgetting that `null` means "no filter" (not "filter by null") — to filter for null values, use `{ "c": "field", "o": "null" }`.
@@ -8,7 +8,7 @@ Lives in: `ui/print_templates.json` + HTML/CSS files under `print_templates/`. S
8
8
 
9
9
  | Path | Engine | Used by |
10
10
  | ----------- | --------------- | -------------------------------------------------------- |
11
- | **Server** | Scriban (Liquid) | `print.render` RPC — final print / save-as-PDF |
11
+ | **Server** | Scriban (Liquid) | `print.render` RPC; `print.renderPdf` (Generate PDF, headless Chromium); `printPdf()` in a DSL action |
12
12
  | **Client** | LiquidJS | Editor preview — fast iteration |
13
13
 
14
14
  Filters are mirrored on both sides so the preview matches the print.
@@ -67,6 +67,12 @@ Every column on the entity is a top-level variable. Display formatting is pre-ap
67
67
 
68
68
  Inside a child-set loop, the same shape applies per row: `line._fmt.<col>`, `line._raw.<col>`, `line._color.<col>`.
69
69
 
70
+ A loop over a document's set can read a set of the line's own entity off each line (`{% for l in lines %}{% for a in l.attributes %}`): detected by the template reading it, loaded for every line in one query under the same rights and row filter, one level deep. The editor preview does not fill it.
71
+
72
+ A printed set, nested or not, needs **Select** on its entity in the folder, as `data.get` does; one the user cannot read prints empty. `print.render` and `print.renderPdf` check Select on the printed entity.
73
+
74
+ A currency column prints its glyph for the common codes (`EUR` → `€`, `USD` → `$`, `GBP` → `£`, `UAH` → `₴`, `PLN` → `zł`); other codes print as the code. `format_currency` follows the same mapping.
75
+
70
76
  Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references or calls a SQL function prints what the grid shows, and `TODAY()` / `NOW()` work. A formula the SQL translator cannot express prints empty and the response carries a warning naming the column.
71
77
 
72
78
  **Sets: loading and order.** A set is loaded when the template's Liquid code (`{% … %}` or `{{ … }}`) mentions its name anywhere: a `for`, a filter, a condition, `.size`, `.first`. Plain text, `{% comment %}` / `{% raw %}` blocks, string literals and the member of a path (`rec.lines`) do not count, and a snippet's code is not scanned (mention the set in the host template). Rows come in primary-key order; sort in the template:
@@ -170,6 +176,8 @@ my_module/
170
176
  - **Forgetting `_raw` for codes.** Direct `{{ status }}` is the label (`"Partially Paid"`). For CSS classes / wire-format payloads / predicates against codes, use `{{ _raw.status }}` (`"P"`).
171
177
  - **Manually formatting dates/currency.** Don't reach for `format_currency` when the column is already `fieldTypeCd: "currency"` — `_fmt.<col>` already matches the form. Use the filter only for derived values.
172
178
  - **Expecting a set to load only from a `for`.** It loads wherever the template names it (`sort`, `.size`, `.first`). A set read only inside a snippet is not seen: mention it in the host template.
179
+ - **Testing emptiness with `blank` / `empty`.** LiquidJS (preview) knows them, Scriban (print) does not, so the comparison is always false on the server for an unset field. Use `{% unless x %}`, `{% if x and x != '' %}` or `x != nil`.
180
+ - **Dashed comment tags.** The server cuts every `{%- comment -%}` block before parsing, but keep snippets to plain `{% comment %}` so both engines agree.
173
181
  - **Snippet `template_cd` with a dot.** `{% include 'my_module.foo.bar' %}` — the resolver splits on the last dot only, so `my_module.foo` becomes the module name and breaks. No dots in snippet codes.
174
182
  - **Dropping CSS in `<style>` blocks inline.** The renderer wires `<style>` for you; declare CSS in the separate `.css` file (or inline string via the `css` field).
175
183
  - **Printing an unsorted set.** Rows come in primary-key order; use `| sort: 'col'` (and `| reverse`) in the template.
@@ -78,7 +78,7 @@ A report has a `label`, a `description`, a `datasets` map, a `layout` object (`{
78
78
  - `query.columns` — column codes; supports dot navigation (`customer.name`).
79
79
  - `query.filter` — canonical filter (`{c,o,v}` or `{g,i:[...]}`); use `@param_code` to reference a parameter.
80
80
  - `query.sort` — `[{ c, d }]` where `d` ∈ `asc | desc`.
81
- - `columnsDef` — optional per-column display overrides (`{ label, visible, width }`).
81
+ - `columnsDef` — optional per-column display overrides (`{ label, fieldTypeCd, format, width }`; each key replaces the projected value). `label` is a literal and wins over a translated header. `visible` is accepted but read by nothing. `baseDatatypeCd` is read only where the dataset projects no columns of its own.
82
82
 
83
83
  ### Stored procedure dataset (developer path)
84
84
 
@@ -110,6 +110,9 @@ Key differences from entity datasets:
110
110
  **override** — `report.run` projects `dForge.sp_column`, and `columnsDef` only restyles it.
111
111
  A function returning an unnamed `SETOF <composite>` or a bare scalar declares no column
112
112
  names, so that one needs an explicit `columns` block in `logic/stored_procedures.json`.
113
+ Leave `label` out of `columnsDef` to keep the column's translated header: result-column headers
114
+ translate under the procedure, `storedProcedures.<sp_cd>.columns.<column_cd>.label`
115
+ (see `references/translations.md`), and every dataset bound to the procedure shows it.
113
116
  - Params work exactly as they do for an entity dataset, and `datasetType` makes no difference: declare them at report level (`parameters`) or on the dataset (`params`). Report level wins on a code collision.
114
117
  - **The parameter code must match the procedure's declared `paramCd` exactly.** `report.run`
115
118
  resolves each argument by that code alone — no prefix is added or stripped — so a report
@@ -83,7 +83,8 @@ Translation files mirror the module's content structure. Each translatable objec
83
83
  | `views` | Data view labels and descriptions (`label`, `desc`) | View codes (keys from `ui/data_views.json`) → `{ label, desc }` |
84
84
  | `menus` | Menu node labels (root + nested items) | Menu root key → `{ label, items: { item_code: { label } } }` matching `ui/menus.json` structure |
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
- | `reports` | Report dataset captions, param labels **and param dropdown option labels** | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label, options: { value: … } } } }` |
86
+ | `reports` | Report `label`, `desc`, dataset captions, param labels **and param dropdown option labels** | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label, options: { value: … } } } }` |
87
+ | `storedProcedures` | Result-column headers of a stored procedure (`logic/stored_procedures.json`), shared by every report bound to it | Procedure codes → `{ columns: { column_cd: { label } } }` |
87
88
  | `entities.<e>.constraints` | Check/unique constraint violation messages (opt-in — warned, not enforced) | Constraint codes → `{ message }` under the owning entity |
88
89
  | `messages` | DSL `info` / `warn` / `error` / `exit` texts (opt-in — warned, not enforced) | Exact English text → translated text |
89
90
  | `entities.<e>.fields.<f>.options` | Dropdown/radio/flags **option labels** (opt-in) | Option `value` → localized label (string) or `{ label, icon, color }` |
@@ -269,6 +270,10 @@ Non-English locales are declared in `supportedLocales` (an array of `ll-CC` tags
269
270
 
270
271
  Every listed locale MUST have a matching `translations/<locale>.json` with a label for each translatable resource, or install fails completeness validation.
271
272
 
273
+ ### Stored-procedure column headers
274
+
275
+ A result column's header belongs to the procedure, not to a report. `module pack` / `module validate` require one `storedProcedures.<sp_cd>.columns.<column_cd>.label` per declared column for each non-English supported locale; install only warns (also when a translation names a column the module lacks). A dataset's `columnsDef` label is a literal and overrides it, so leave the label out to keep the column translated.
276
+
272
277
  ## Rules
273
278
 
274
279
  1. **Always provide `en-US`** as the base language. Other languages fall back to it for missing keys.