@dforge-core/dforge-mcp 0.2.44 → 0.2.45
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 +61 -0
- package/README.md +1 -1
- package/dist/server.js +109 -84
- package/package.json +3 -3
- package/resources/docs/conventions.md +17 -14
- package/resources/docs/dsl-reference.md +15 -5
- package/resources/schemas/entity.schema.json +1 -1
- package/resources/schemas/registers.schema.json +8 -3
- package/skills/dforge-mcp-author/SKILL.md +3 -2
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
- package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
- package/skills/dforge-mcp-author/references/conventions.md +7 -1
- package/skills/dforge-mcp-author/references/field-types.md +3 -2
- package/skills/dforge-mcp-author/references/formulas.md +5 -1
- package/skills/dforge-mcp-author/references/print-templates.md +5 -2
- package/skills/dforge-mcp-author/references/queries.md +1 -1
- package/skills/dforge-mcp-author/references/registers.md +18 -3
- package/skills/dforge-mcp-author/references/reports.md +4 -1
- package/skills/dforge-mcp-author/references/schema-import.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +1 -1
- package/skills/dforge-mcp-author/references/translations.md +38 -15
- package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
- package/skills/dforge-module-build/SKILL.md +10 -1
- package/skills/dforge-module-ship/SKILL.md +31 -3
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
|
@@ -145,6 +145,7 @@ A param declared with a text field type (`text`, `textarea`, ...) stays a string
|
|
|
145
145
|
|
|
146
146
|
- `[fieldName]` — read or write a field on the current record
|
|
147
147
|
- `[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
|
|
148
|
+
- `[userField].[email]` — a column of `fieldTypeCd: "user"` (an `owner_id`, an approver) is not a Reference column but navigates the same way, to the platform user: `[owner_id].[email]`, `[owner_id].[full_name]`. This is the only way to read a user's name or e-mail — `getRecord()`, `select()` and `query()` cannot reach the user table. Needs a platform with #1499 (v1.28.8+)
|
|
148
149
|
- `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
|
|
149
150
|
|
|
150
151
|
```dsl
|
|
@@ -504,7 +505,7 @@ notify([approver_id], 'New purchase request from ' + [requester_name] + ' awaiti
|
|
|
504
505
|
```
|
|
505
506
|
|
|
506
507
|
#### `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
|
|
508
|
+
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()`, `attachText()` or `attachBase64()` 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 `printPdf()` file, or a workspace admin, can attach it, and a scheduled job cannot; a file built with `attachText()` / `attachBase64()` has no owner and works in a scheduled job too.
|
|
508
509
|
|
|
509
510
|
```dsl
|
|
510
511
|
// Raw mode: (to, subject, body)
|
|
@@ -577,13 +578,22 @@ execute:
|
|
|
577
578
|
```
|
|
578
579
|
|
|
579
580
|
#### `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
|
+
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 (shared with `attachText()` / `attachBase64()`).
|
|
581
582
|
|
|
582
583
|
```dsl
|
|
583
584
|
execute:
|
|
584
585
|
downloadText(csv, 'payments.txt', 'windows-1251')
|
|
585
586
|
```
|
|
586
587
|
|
|
588
|
+
#### `attachText(content, fileName, encoding?, mimeType?)` / `attachBase64(base64, fileName, mimeType?)`
|
|
589
|
+
Build a file to attach to an email: pass the result as `sendEmail()`'s fourth argument. Encodings as `downloadText()`; the file keeps its `mimeType` (`text/plain` for `attachText()` by default, `application/octet-stream` for `attachBase64()`). Returns a descriptor, not a URL, and only the emails of the same run can name it. Works in a scheduled job. Max 10 MB per file; the 10-files-per-run limit is shared with the download functions.
|
|
590
|
+
|
|
591
|
+
```dsl
|
|
592
|
+
execute:
|
|
593
|
+
sendEmail([candidate_email], 'Interview for ' + [job_title], '<p>See you.</p>',
|
|
594
|
+
attachText(ics, 'invite.ics', 'utf-8', 'text/calendar'))
|
|
595
|
+
```
|
|
596
|
+
|
|
587
597
|
#### `download(url, fileName?)`
|
|
588
598
|
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.
|
|
589
599
|
|
|
@@ -739,8 +749,8 @@ The first argument names a **register** (`module_cd.register_cd`, as declared in
|
|
|
739
749
|
| `getBalance('module.register', dims?, asOf?)` | Object, per resource | Balance of an accumulation register. A dimension left out of `dims` is summed over. |
|
|
740
750
|
| `getTurnover('module.register', dims, from, to)` | Object, `{in, out, net}` per resource | Movement over an interval. `dims` may be `null`; `from` and `to` are required. |
|
|
741
751
|
| `getSlice('module.register', dims, onDate?, mode?)` | Object or `null` | One record of an information register, the latest on or before `onDate`, plus `_period`. No dimension may be omitted. |
|
|
742
|
-
| `getAccountBalance('module.register', account, filter?, asOf?)` | Object, `{debit, credit, net}` per resource | Balance of one account of an accounting register. |
|
|
743
|
-
| `getAccountTurnover('module.register', account, filter, from, to)` | Object, `{debit, credit, net}` per resource | Movement of one account over an interval. |
|
|
752
|
+
| `getAccountBalance('module.register', account, filter?, asOf?, options?)` | Object, `{debit, credit, net}` per resource; an array of rows with `groupBy` | Balance of one account of an accounting register. `options`: `groupBy` (a name or array — a row per combination, zero balances left out), `exclude: { entity: 'module.document', key }` (one, or an array of them; not that document's postings), `before` (a moment, exclusive, in place of `asOf`). |
|
|
753
|
+
| `getAccountTurnover('module.register', account, filter, from, to, options?)` | Object, `{debit, credit, net}` per resource; an array of rows with `groupBy` | Movement of one account over an interval. `options` takes `groupBy` and `exclude`. |
|
|
744
754
|
| `getCorrespondence('module.register', debitAccount, creditAccount, filter, from, to)` | Object, per resource | Movement between two accounts. Needs `lineMode: "compound"`; refuses an analytic filter. |
|
|
745
755
|
|
|
746
756
|
An analytic filter is the key alone (`{ cost_center: 42 }`) or `{ entity, value }` to name one entity of a union kind.
|
|
@@ -1197,7 +1207,7 @@ The `audit` trait auto-maintains `last_updated` on INSERT/UPDATE if the column e
|
|
|
1197
1207
|
| Run SQL | `query('SELECT ... WHERE c = @v', { v: value })` |
|
|
1198
1208
|
| Get setting | `getSetting('setting_cd')` |
|
|
1199
1209
|
| Send toast | `info('msg')`, `warn('msg')` |
|
|
1200
|
-
| Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)`; add a `printPdf()` result as the 4th argument to attach |
|
|
1210
|
+
| Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)`; add a `printPdf()`, `attachText()` or `attachBase64()` result as the 4th argument to attach |
|
|
1201
1211
|
| Give the user a built file | `downloadText(content, 'f.txt', 'windows-1251')` |
|
|
1202
1212
|
| Send inbox notification | `notify(userId, 'msg')` |
|
|
1203
1213
|
| Call a shared helper | `round2([amount])` from `logic/functions/*.dsl` |
|
|
@@ -428,7 +428,7 @@
|
|
|
428
428
|
"fieldTypeCd": {
|
|
429
429
|
"type": "string",
|
|
430
430
|
"enum": [
|
|
431
|
-
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
431
|
+
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent", "filesize",
|
|
432
432
|
"checkbox", "date", "datetime", "time",
|
|
433
433
|
"dropdown", "flags", "tags", "json", "hidden", "color", "image", "file",
|
|
434
434
|
"markdown", "richtext", "lookup", "user", "grid", "list", "entitylink", "code"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"account": false,
|
|
25
25
|
"debitAccount": false, "creditAccount": false,
|
|
26
26
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
27
|
-
"sideDimensions": false, "analyticTotals": false
|
|
27
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false
|
|
28
28
|
},
|
|
29
29
|
"description": "A balance register keeps state, so it needs the entity that holds it. It dates its movements on the journal row under 'journalDate'; 'period' belongs to an information register, whose records ARE the dated thing. Its writer is recorded on the journal row, which is what 'recorder' names for an information register that has no journal."
|
|
30
30
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"account": false,
|
|
42
42
|
"debitAccount": false, "creditAccount": false,
|
|
43
43
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
44
|
-
"sideDimensions": false, "analyticTotals": false,
|
|
44
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false,
|
|
45
45
|
"allowNegative": false,
|
|
46
46
|
"autoCreate": false
|
|
47
47
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"account": false,
|
|
61
61
|
"debitAccount": false, "creditAccount": false,
|
|
62
62
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
63
|
-
"sideDimensions": false, "analyticTotals": false,
|
|
63
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false,
|
|
64
64
|
"totals": false,
|
|
65
65
|
"allowNegative": false,
|
|
66
66
|
"autoCreate": false
|
|
@@ -375,6 +375,11 @@
|
|
|
375
375
|
"items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
|
|
376
376
|
"description": "Analytic slot columns for the CREDITED account, in slot order and capped independently of the debit side. Only valid on a compound register."
|
|
377
377
|
},
|
|
378
|
+
"importedKeyField": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"pattern": "^[a-z_][a-z0-9_]*$",
|
|
381
|
+
"description": "Accounting only. The posting column holding the document's key on a posting LOADED beside a document posted in another system — one with no 'source' stamp. getAccountBalance / getAccountTurnover 'exclude' then leaves such postings out by that key as well as the stamped ones. Matches documents with a one-column key."
|
|
382
|
+
},
|
|
378
383
|
"sideDimensions": {
|
|
379
384
|
"type": "object",
|
|
380
385
|
"minProperties": 1,
|
|
@@ -147,12 +147,13 @@ Load these by URI, one per step, at the step that needs them.
|
|
|
147
147
|
`data-views`, `menus`, `action-dsl`, `filters`, `security`, `reports`,
|
|
148
148
|
`settings`, `jobs`, `number-sequences`, `registers`, `reactions`, `document-extraction`,
|
|
149
149
|
`print-templates`, `translations`, `queries`, `schema-import`, `excel-import`,
|
|
150
|
-
`data-migration`, `manifest`, `validation-checklist
|
|
150
|
+
`data-migration`, `manifest`, `conventions`, `validation-checklist`.
|
|
151
151
|
|
|
152
152
|
**Schemas** (fallback, when a reference points at one) — `dforge://schema/<name>`:
|
|
153
153
|
`manifest`, `entity`, `domains`, `data-views`, `folders`, `menus`, `roles`,
|
|
154
154
|
`jobs`, `seed-data`, `traits`, `webhooks`, `triggers`, `print-templates`,
|
|
155
|
-
`settings`, `reports
|
|
155
|
+
`settings`, `reports`, `deps`, `registers`, `translations`, `diagram`,
|
|
156
|
+
`stored-procedures`, `card-layouts`, `demo-vars`.
|
|
156
157
|
|
|
157
158
|
**Docs**: `dforge://docs/dsl` (full DSL grammar + built-ins),
|
|
158
159
|
`dforge://docs/conventions` (cross-module extension work — §1b only).
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"column_security.storekeeper": {
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "Storekeeper",
|
|
4
4
|
"rights": {
|
|
5
5
|
"product": "SIU",
|
|
6
6
|
"folder:warehouse": "E"
|
|
7
7
|
}
|
|
8
8
|
},
|
|
9
9
|
"column_security.accountant": {
|
|
10
|
-
"description": "
|
|
10
|
+
"description": "Accountant",
|
|
11
11
|
"rights": {
|
|
12
12
|
"product": "SIU",
|
|
13
13
|
"folder:finance": "E"
|
|
@@ -238,9 +238,12 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
238
238
|
| `preloadRef('fk_column')` | Row object | Load the referenced record for a FK column. Access its fields with `ref.get('field')`. |
|
|
239
239
|
|
|
240
240
|
> **Entity codes: always qualify.** Every data built-in (`select`, `insert`, `update`, `delete`,
|
|
241
|
-
> `getRecord`, `nextNumber`)
|
|
242
|
-
>
|
|
243
|
-
>
|
|
241
|
+
> `getRecord`, `nextNumber`) takes `module.entity` — write `update('comm.message', …)`, not
|
|
242
|
+
> `update('message', …)`, even inside the owning module. It reads unambiguously and keeps the script
|
|
243
|
+
> correct if it's ever moved to a bridge module. The part before the dot is looked up first as a module
|
|
244
|
+
> code, hyphen included (`insert('cdy-tax.invoice', …)`), and otherwise taken as a schema
|
|
245
|
+
> (`insert('cdy_tax.invoice', …)`); both work, also for a hyphenated module with no `schemaName`
|
|
246
|
+
> (platform #1543). Prefer the module code, as a `ref` param writes it.
|
|
244
247
|
|
|
245
248
|
> **Keys: scalar or object.** In `update()` / `delete()` / `getRecord()` / `getRecordOrNull()`,
|
|
246
249
|
> `key` is either a scalar PK value (`update('task', 42, {…})` — single-column PK only) or an
|
|
@@ -319,12 +322,14 @@ The first argument names a **register** (`module_cd.register_cd`, as declared in
|
|
|
319
322
|
| `getBalance('module.register', dims?, asOf?)` | Object, per resource | Balance of an accumulation register. A dimension left out of `dims` is summed over. |
|
|
320
323
|
| `getTurnover('module.register', dims, from, to)` | Object, `{in, out, net}` per resource | Movement over an interval. `dims` may be `null`; `from` and `to` are required. |
|
|
321
324
|
| `getSlice('module.register', dims, onDate?, mode?)` | Object or `null` | One record of an information register, the latest on or before `onDate`, plus `_period`. No dimension may be omitted. |
|
|
322
|
-
| `getAccountBalance('module.register', account, filter?, asOf?)` | Object, `{debit, credit, net}` per resource | Balance of one account of an accounting register. |
|
|
323
|
-
| `getAccountTurnover('module.register', account, filter, from, to)` | Object, `{debit, credit, net}` per resource | Movement of one account over an interval. |
|
|
325
|
+
| `getAccountBalance('module.register', account, filter?, asOf?, options?)` | Object, `{debit, credit, net}` per resource; an array of rows with `groupBy` | Balance of one account of an accounting register. `options`: `groupBy` (a name or array — a row per combination, zero balances left out), `exclude: { entity: 'module.document', key }` (not that document's postings), `before` (a moment, exclusive, in place of `asOf`). |
|
|
326
|
+
| `getAccountTurnover('module.register', account, filter, from, to, options?)` | Object, `{debit, credit, net}` per resource; an array of rows with `groupBy` | Movement of one account over an interval. `options` takes `groupBy` and `exclude`. |
|
|
324
327
|
| `getCorrespondence('module.register', debitAccount, creditAccount, filter, from, to)` | Object, per resource | Movement between two accounts. Needs `lineMode: "compound"`; refuses an analytic filter. |
|
|
325
328
|
|
|
326
329
|
An analytic filter is the key alone (`{ cost_center: 42 }`) or `{ entity, value }` to name one entity of a union kind.
|
|
327
330
|
|
|
331
|
+
`getAccountBalance` / `getAccountTurnover` take `exclude` as one `{ entity, key }` or an array of them, of any entities. A register that declares `importedKeyField` (see the registers reference) also leaves out postings *loaded* beside a document, matched by that document's key.
|
|
332
|
+
|
|
328
333
|
### Messaging
|
|
329
334
|
|
|
330
335
|
| Function | Description |
|
|
@@ -348,7 +353,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
|
|
|
348
353
|
|---|---|
|
|
349
354
|
| `sendEmail(to, subject, htmlBody)` | Send a raw email. `to` is an email address string. |
|
|
350
355
|
| `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).
|
|
356
|
+
| `sendEmail(to, subjectOrTemplate, dataOrBody, attachments)` | The optional fourth argument attaches files: a `printPdf()`, `attachText()` or `attachBase64()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference). A `printPdf()` file can be attached only by the user who generated it or a workspace admin, and a scheduled job's email carrying one fails and rolls back; a file built with `attachText()` / `attachBase64()` has no owner and works in a scheduled job too. At most 5 files and 10 MB per email. A file the sender may not attach fails the action. |
|
|
352
357
|
|
|
353
358
|
### PDF and file output
|
|
354
359
|
|
|
@@ -356,6 +361,8 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
|
|
|
356
361
|
|---|---|---|
|
|
357
362
|
| `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
363
|
| `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. |
|
|
364
|
+
| `attachText(content, fileName, encoding?, mimeType?)` | descriptor | Build a text file for `sendEmail()`'s fourth argument, keeping its `mimeType` (e.g. an `.ics` as `text/calendar`). Same encodings as `downloadText()`; max 10 MB per file, 10 files per run shared with the download functions. Works in a scheduled job. |
|
|
365
|
+
| `attachBase64(base64, fileName, mimeType?)` | descriptor | `attachText()` for bytes. |
|
|
359
366
|
| `downloadBase64(base64, fileName, mimeType?)` | URL string | `downloadText()` for bytes; `application/octet-stream` unless `mimeType` says otherwise. |
|
|
360
367
|
|
|
361
368
|
### External API Calls
|
|
@@ -700,6 +707,8 @@ In `batch` mode, access individual records with `__records.get(i)`, the count wi
|
|
|
700
707
|
|
|
701
708
|
## Async actions
|
|
702
709
|
|
|
710
|
+
An inline run stops after **10 seconds** (`Dsl:Limits:ScriptTimeoutSeconds`) with "The operation has timed out.", rolling its transaction back. Work that imports or posts many documents belongs in a background run; a caller that needs the outcome waits for the records' state (a posting flag) rather than for the call.
|
|
711
|
+
|
|
703
712
|
For long-running actions, set `"isAsync": true` in `ui/actions.json`. That **permits** background execution: the call is queued as a `background_action` row and picked up by `BackgroundActionService` (a hosted `BackgroundService` polling every ~10s — there is no Hangfire in dForge), and the user gets a completion notification via SSE. The DSL syntax is the same.
|
|
704
713
|
|
|
705
714
|
`isAsync` does not force it. `action.execute` carries its own `async` argument and the server branches on that, so a parameterless action is queued directly while one with parameters is offered to the user as both "Run" (inline) and "Run in Background". Note that a queued run has **no wrapping transaction** — see *Atomicity* below.
|
|
@@ -746,6 +755,8 @@ In `ui/actions.json`:
|
|
|
746
755
|
| `isTransacted` | Recommended | `true` = the whole run is **one transaction**: the first failure aborts it *and* rolls back everything already written. `false` = no transaction, and what happens next depends on the mode — `single`/`each` report the failure and carry on with the next record, keeping what succeeded; a `batch` script is one invocation, so a failure just ends the run there with its earlier writes committed and its `[field]` assignments never flushed. **Defaults to `true`** when omitted — see *Atomicity* below |
|
|
747
756
|
| `orderNum` | Recommended | Display order in the action menu |
|
|
748
757
|
| `isAsync` | Optional | `true` **permits** background execution — it does not force it. A parameterless action is queued directly; one with params is offered to the user as both "Run" and "Run in Background". Queued runs get a completion notification |
|
|
758
|
+
| `previewTemplate` | Optional | Code of a print template of the action's entity (`ui/print_templates.json`). The run dialog opened on one record renders it as a PDF beside the params, so the user reads what the action sends before running it. Each preview renders and archives a PDF |
|
|
759
|
+
| `paramSets` | Optional | Saved parameter sets the module ships, keyed by set name: `{ "default": { "description": "…", "values": { "subject": "…" } } }`. Read-only for users and refreshed on every install; a `values` key the action does not declare fails the install. A set named `default` is applied when the dialog opens. A value starting with `=` is a formula over the record (`=[customer].[email]`) and one containing `{{ }}` is a print-style template, both evaluated when the dialog opens on one record. A user's own set of the same name overrides it for that user, and a set an admin saved under a shipped name is left alone with a warning |
|
|
749
760
|
|
|
750
761
|
**Definitions are cached.** The server reads an action's definition through a per-tenant cache (5 minutes), cleared on module install, uninstall and Studio publish. A CLI install is noticed only through a connected browser, so without one a changed action can run stale for up to 5 minutes.
|
|
751
762
|
|
|
@@ -809,7 +820,7 @@ be either.
|
|
|
809
820
|
> and `ON COMMIT DROP` on any temp table. Only `query()` can do any of this — the
|
|
810
821
|
> structured built-ins emit plain DML — and nothing checks it statically.
|
|
811
822
|
|
|
812
|
-
1. **Bare table names are qualified automatically** with the current module's schema after `FROM` / `JOIN` / `USING`, and as the target of `INSERT INTO`, `MERGE INTO` and `UPDATE`. A name already qualified (`wms.stock_movement`) is left as written, so qualify only another module's table. Left alone: words that name no table (`IS DISTINCT FROM`, `EXTRACT(year FROM d)`, `FOR UPDATE`, `DO UPDATE SET`, `SELECT ... INTO var`), built-in set functions (`generate_series`, `unnest`), literals and comments. A CTE name is a source only inside the query that declares its `WITH` (and in its own body only under `RECURSIVE`); a write target is always the real table, so `WITH t AS (...) UPDATE t ...` updates the module's `t`. `;`-separated statements are each qualified. (
|
|
823
|
+
1. **Bare table names are qualified automatically** with the current module's schema after `FROM` / `JOIN` / `USING`, and as the target of `INSERT INTO`, `MERGE INTO` and `UPDATE`. A name already qualified (`wms.stock_movement`) is left as written, so qualify only another module's table. Left alone: words that name no table (`IS DISTINCT FROM`, `EXTRACT(year FROM d)`, `FOR UPDATE`, `DO UPDATE SET`, `SELECT ... INTO var`), built-in set functions (`generate_series`, `unnest`), literals and comments. A CTE name is a source only inside the query that declares its `WITH` (and in its own body only under `RECURSIVE`); a write target is always the real table, so `WITH t AS (...) UPDATE t ...` updates the module's `t`. `;`-separated statements are each qualified. (A module whose code has a hyphen and that declares no `schemaName` lives in a hyphenated schema, which raw SQL cannot name as written — the structured built-ins reach it by module code; give such a module a `schemaName` with underscores if its tables are queried in SQL.)
|
|
813
824
|
2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
|
|
814
825
|
3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
|
|
815
826
|
4. **`insert()` is preferred over `query('INSERT ...')`**, `update()`/`delete()` over `query('UPDATE/DELETE ...')`, and `select()` over `query('SELECT ...')` for module entities — raw SQL skips auto-fill (PKs, audit, number sequences) and metadata validation.
|
|
@@ -825,6 +836,8 @@ be either.
|
|
|
825
836
|
- Passing an **array of ids** as the `key` of `update()`/`delete()` — **rejected**. Loop over the ids and mutate one at a time.
|
|
826
837
|
- Selecting or string-filtering a plain reference (`R`) column in `select()` — **throws**. Use the FK column or a nav path (`'customer.company_name'`).
|
|
827
838
|
- Unqualified entity codes (`update('message', …)`) — works inside the owning module but **always prefer the qualified form** (`update('comm.message', …)`).
|
|
839
|
+
- An assignment inside a one-line block, `if (x) { [field] = y }` -- fails at run time with "not a valid reference". Put `[field] = y` on a line of its own.
|
|
840
|
+
- Indexing straight after a call or an index, `query(sql)[0]` or `rows[0][name]` -- read as a field reference `[0]` / `[name]` before platform #1537 (2026-10-24). On an older platform, assign the result to a variable first. `rows [name]`, with a space, is still a field read.
|
|
828
841
|
- Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
|
|
829
842
|
- Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
|
|
830
843
|
- Calling `TODAY()` or `NOW()` (uppercase) inside `execute:` — **wrong**, they're undefined there and install fails with `'TODAY' is not defined`. Use lowercase **`now()`** in `execute:`; `TODAY()`/`NOW()` are formula-only (`canExecute:`, formula columns).
|
|
@@ -114,6 +114,10 @@ Use explicit **numeric** PKs in seed data so cross-file references work. The `id
|
|
|
114
114
|
|
|
115
115
|
**Common mistake**: using UUID strings like `"10000000-0000-0000-0000-000000000001"` — **wrong**. The `cuid` datatype is a numeric `int8`, not a UUID string. Seed data PKs must be numbers. Use a numbering scheme that avoids collisions (e.g. 1001-1099 for countries, 2001-2099 for regions, etc.).
|
|
116
116
|
|
|
117
|
+
**Seed data is re-applied on every install.** Reinstalling writes the seed rows back over rows a user changed and brings back rows a user deleted. Seed only reference data the module owns outright; a table users or a migration fill must not be seeded, or each install undoes their work.
|
|
118
|
+
|
|
119
|
+
**Seed data and settings ship in the package.** Both go into the `.dforge` file and the marketplace listing. Put only fictional or public reference data there — never a real company name, tax id, address or brand.
|
|
120
|
+
|
|
117
121
|
## Demo data
|
|
118
122
|
|
|
119
123
|
Sample records live in `demo-data/`, not `seed-data/`. `seed-data/` holds reference tables every install needs; `demo-data/` is loaded only on request, so a workspace for real work does not start full of fake customers and invoices.
|
|
@@ -129,6 +133,8 @@ Sample records live in `demo-data/`, not `seed-data/`. `seed-data/` holds refere
|
|
|
129
133
|
|
|
130
134
|
Depth: the platform changelog entry "Demo data: its own folder, formulas, and a checkbox at install".
|
|
131
135
|
|
|
136
|
+
**`demo-data/` inserts rows; it does not post them.** For an accounting or stock module the demo must show posted documents — balances, postings, a closed month — so a demo built only from files shows documents and empty registers. Load such a demo through the API instead: insert the documents, then run the module's own actions (post, import, close) on them, waiting for a background action by its record's state flag. Make it idempotent (fixed `DEMO-` codes, skip what exists) so it can run again.
|
|
137
|
+
|
|
132
138
|
## Translations
|
|
133
139
|
|
|
134
140
|
Translation files live in `translations/` and are named after the **IETF locale tag** (`de-DE.json`, `uk-UA.json`, etc.). Filenames are auto-discovered; declare which ones you ship in `manifest.supportedLocales`. English is the default — labels and descriptions live in entity/view JSON, so **don't** create `en.json` or `en-US.json` (they're skipped by the installer).
|
|
@@ -163,7 +169,7 @@ Both use **semver** (`MAJOR.MINOR.PATCH`).
|
|
|
163
169
|
- If you changed translations only → bump `version` only.
|
|
164
170
|
- The installer uses `dbSchemaVersion` to decide whether to apply schema changes. Wrong version = skipped DDL.
|
|
165
171
|
- Data a new version reshapes (backfill, move rows, drop a table it no longer declares) goes in `migrations/<version>.sql`, named by `version`, run once on an upgrade from below it. See `manifest.md` § Upgrade migrations.
|
|
166
|
-
- **Never ship a package without bumping at least `version`** from the previous release.
|
|
172
|
+
- **Never ship a package without bumping at least `version`** from the previous release. A re-install of the same version is **skipped and reported as a success** (`isEmpty: true`; only the server log says so), so a smoke test then runs against the old code. Bump the version, or pass `--force` while developing.
|
|
167
173
|
- For brand-new modules, start at `version: "0.1.0"` and `dbSchemaVersion: "0.0.1"`.
|
|
168
174
|
|
|
169
175
|
## `orderNum` — always set it
|
|
@@ -52,7 +52,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
52
52
|
| `fieldTypeCd` | `baseDatatypeCd` | Description | Common `dbDatatype` | Params |
|
|
53
53
|
|---|---|---|---|---|
|
|
54
54
|
| `date` | `date` | Calendar date only. | `date` | `min`, `max` (ISO strings or `TODAY()`) |
|
|
55
|
-
| `datetime` | `timestamp` | Date + time with timezone. | `timestamptz` | `min`, `max` |
|
|
55
|
+
| `datetime` | `timestamp` | Date + time with timezone. Shown in the viewer's zone, unless `timeZone` pins one. | `timestamptz` | `min`, `max`, `timeZone` (an IANA name or `"UTC"`: show and edit on that zone's wall clock — a meeting, a check-in at a place; the stored value stays the UTC instant; an unknown zone is ignored) |
|
|
56
56
|
| `time` | `time` | Time of day. | `time` | — |
|
|
57
57
|
|
|
58
58
|
## Binary / file
|
|
@@ -89,7 +89,8 @@ validation (`entity.schema.json` is `additionalProperties: false`). To give a co
|
|
|
89
89
|
|
|
90
90
|
| Need | Do this |
|
|
91
91
|
|---|---|
|
|
92
|
-
| Literal / computed default (`'draft'`, `TODAY()`) | Carry a `formula` on the column (formula context — uppercase `TODAY()`/`NOW()`), e.g. `"formula": "'draft'"` or `"formula": "TODAY()"`. |
|
|
92
|
+
| Literal / computed default (`'draft'`, `TODAY()`) | Carry a `formula` on the column (formula context — uppercase `TODAY()`/`NOW()`), e.g. `"formula": "'draft'"` or `"formula": "TODAY()"`. A number literal (`"0.25"`, an FK id `"50002"`) becomes the column's database `DEFAULT` when the column can hold it (never on a primary key or identity column). |
|
|
93
|
+
| A module setting's value (`$[default_vat]`) | `"formula": "$[default_vat]"` — exactly the reference, nothing around it. An insert or import that leaves the column out takes the setting's value (folder overrides first, then the module's), and an upgrade backfills a new required column from it. No trigger needed. |
|
|
93
94
|
| Document number (PO-2026-0001) | Declare a `numberSequence` on the entity — auto-fills on insert. |
|
|
94
95
|
| Anything set at create time | Set it in an action/trigger DSL (`execute:` uses lowercase `now()`). |
|
|
95
96
|
|
|
@@ -104,8 +104,10 @@ FORMAT([created_date], 'yyyy-MM-dd')
|
|
|
104
104
|
|
|
105
105
|
### Date functions
|
|
106
106
|
|
|
107
|
-
- `TODAY()` — current date
|
|
107
|
+
- `TODAY()` — current date, in the request's time zone
|
|
108
108
|
- `NOW()` — current datetime with timezone
|
|
109
|
+
|
|
110
|
+
The request's zone is the browser's (sent on every call), else the user's profile zone, else the workspace's, else UTC. Before platform 2026-10-25 both were always UTC, so `TODAY()` was a day off around midnight for a viewer far from UTC. Scheduled jobs still run on UTC.
|
|
109
111
|
- `YEAR(d)`, `MONTH(d)`, `DAY(d)` — date parts (`MONTH` is 1-based)
|
|
110
112
|
- `HOUR(dt)`, `MINUTE(dt)`, `SECOND(dt)` — time parts
|
|
111
113
|
- `WEEKDAY(d)` — day of week, `0` = Sunday
|
|
@@ -380,6 +382,8 @@ An `F` column is virtual: not stored, not editable. To fill a **stored, editable
|
|
|
380
382
|
|
|
381
383
|
## Common mistakes
|
|
382
384
|
|
|
385
|
+
- Dividing by a column that can be zero (`[a] / [b]`) — wherever the formula runs in SQL (a stored `G` column, a filter, a sort, a report) a zero divisor fails the statement with `division by zero`. Guard it (`CASE([b] = 0, 0, [a] / [b])`) or store the result from an action.
|
|
386
|
+
|
|
383
387
|
- Using `column_name` without brackets — **wrong**. Always `[column_name]`.
|
|
384
388
|
- Inventing functions like `HAS_PERMISSION()`, `IS_ADMIN()`, `GET_USER()` — **do not exist**. Do not use unless confirmed to exist in the actual dForge engine.
|
|
385
389
|
- Using JavaScript syntax like `row.field` or `this.field` — **wrong**. Only `[field]`.
|
|
@@ -94,7 +94,9 @@ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does
|
|
|
94
94
|
| `format_date: <strftime>` | `{{ invoice_date | format_date: '%B %d, %Y' }}` | `"May 22, 2026"` |
|
|
95
95
|
| `qr_code: <size?>, <ecc?>, <margin?>, <format?>`| `{{ payment_url | qr_code: 200, 'M' }}` | `data:image/png;base64,…` |
|
|
96
96
|
|
|
97
|
-
`format_date` supports strftime: `%Y %m %d %B %b %H %M %S %I %p %A %a %e`.
|
|
97
|
+
`format_date` supports strftime: `%Y %m %d %B %b %H %M %S %I %p %A %a %e`. `%B` / `%b` / `%A` / `%a` print **English** names whatever the user's language; for a month in another language keep a small snippet that maps `%m` to the name. `format_date` returns a **string**: arithmetic on its result fails ("Operator - not supported on string"), so compute on the raw value and format last.
|
|
98
|
+
|
|
99
|
+
**Time zones.** A timestamp in `_fmt` and `format_date` on an instant render in the column's `params.timeZone`, else the request's zone (before platform 2026-10-25, always UTC). A bare date or a timestamp without a zone prints as written.
|
|
98
100
|
|
|
99
101
|
## Snippets
|
|
100
102
|
|
|
@@ -176,7 +178,8 @@ my_module/
|
|
|
176
178
|
- **Forgetting `_raw` for codes.** Direct `{{ status }}` is the label (`"Partially Paid"`). For CSS classes / wire-format payloads / predicates against codes, use `{{ _raw.status }}` (`"P"`).
|
|
177
179
|
- **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.
|
|
178
180
|
- **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
|
|
181
|
+
- **Testing emptiness with `blank` / `empty`.** LiquidJS (preview) knows them, Scriban (print) does not: on the server `x == blank` is false for nil and `''`, and true for a date. Use `{% unless x %}`, `{% if x and x != '' %}` or `x != nil`.
|
|
182
|
+
- **Joining text across an empty reference.** A formula that glues several navigated parts (`[frp].[employee].[full_name]`) yields stray spaces when a hop is empty; `| strip` the result. Every role that prints needs Select on each entity of the path, or the value prints empty with a warning.
|
|
180
183
|
- **Dashed comment tags.** The server cuts every `{%- comment -%}` block before parsing, but keep snippets to plain `{% comment %}` so both engines agree.
|
|
181
184
|
- **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.
|
|
182
185
|
- **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).
|
|
@@ -198,7 +198,7 @@ Queries are not in menu and not integrated into menu, they have separate section
|
|
|
198
198
|
|
|
199
199
|
## Translations
|
|
200
200
|
|
|
201
|
-
Query names and descriptions are translated in `translations
|
|
201
|
+
Query names and descriptions are translated in each non-English `translations/<locale>.json` (English is the query's own text — install skips an English file). For example `de-DE.json`:
|
|
202
202
|
|
|
203
203
|
```json
|
|
204
204
|
{
|
|
@@ -17,6 +17,8 @@ Lives in: `registers.json` at the module root (a keyed dictionary, like `logic/j
|
|
|
17
17
|
|
|
18
18
|
A register earns its place when something must be guarded or cached by its key: a negative-stock check, a unique slice, a period lock. Posting and unposting are then the platform's job, not a DSL's.
|
|
19
19
|
|
|
20
|
+
Only a `balance` register folds movements into **one row per dimension tuple** (its state entity). A `turnover` register keeps every movement as a journal row and sums at read time; an `info` register keeps one record per key and `period`. So "how much is there now, per key" is `balance`; "how much moved" is `turnover`; "what was the value on a date" is `info`.
|
|
21
|
+
|
|
20
22
|
## The kinds
|
|
21
23
|
|
|
22
24
|
| `kind` | Keeps | Written by | Read by | Key rules |
|
|
@@ -243,8 +245,8 @@ Paths, expressions, `dateTrunc`, `headerMap`, `groupLinesBy`, `lineDateField` an
|
|
|
243
245
|
| `getBalance(register, dims?, asOf?)` | `balance` | `{ resource: value }`, zeros when empty |
|
|
244
246
|
| `getTurnover(register, dims, from, to)` | `balance`, `turnover` | `{ resource: { in, out, net } }` |
|
|
245
247
|
| `getSlice(register, dims, onDate?, mode?)` | `info` | Record plus `_period`, or `null`. `mode` is `last` (default) or `first`. |
|
|
246
|
-
| `getAccountBalance(register, account, filter?, asOf?)` | `accounting` | `{ resource: { debit, credit, net } }` |
|
|
247
|
-
| `getAccountTurnover(register, account, filter, from, to)` | `accounting` | Same shape |
|
|
248
|
+
| `getAccountBalance(register, account, filter?, asOf?, options?)` | `accounting` | `{ resource: { debit, credit, net } }`; with `options.groupBy`, an array of rows |
|
|
249
|
+
| `getAccountTurnover(register, account, filter, from, to, options?)` | `accounting` | Same shape |
|
|
248
250
|
| `getCorrespondence(register, debitAccount, creditAccount, filter, from, to)` | compound | Same shape |
|
|
249
251
|
|
|
250
252
|
```
|
|
@@ -257,6 +259,8 @@ execute:
|
|
|
257
259
|
- As-of reads and `getTurnover` need a journal with both stamps.
|
|
258
260
|
- A read of the wrong kind is refused, naming the right one.
|
|
259
261
|
- DSL reads are gated by the action's Execute right, not Select on the register tables.
|
|
262
|
+
- **Account read options:** `groupBy` (a name or array; a row per combination, zero balances left out), `exclude` (one `{ entity, key }` or an array of them, of any entities, left out together; an empty array leaves nothing out; a refusal names the entry, `exclude[1]`), and on a balance `before` (up to that moment, exclusive and to the precision of the journal's date column, in place of `asOf`'s end of day; giving both is refused).
|
|
263
|
+
- **Balance at a document's moment.** The platform keeps no order between movements stamped in the same second, so "the balance just before this document" is the caller's decision: read `before` the end of that second and `exclude` the document itself plus every document of that second the caller orders after it (1C's МоментВремени orders by date, then reference). `exclude` matches the postings each document wrote by its `source` stamp; postings loaded from another system carry no stamp and are kept unless the register declares `importedKeyField` (below).
|
|
260
264
|
- **Reports:** a dataset with `"datasetType": "R"` reads an accounting register over an interval. It returns one row per chart account with `opening_*`, `turnover_*` and `closing_*` debit and credit for one resource. See [`reports.md`](reports.md) and [`docs/business-logic/gl-ledger-to-declared-register.md`](../../../docs/business-logic/gl-ledger-to-declared-register.md).
|
|
261
265
|
|
|
262
266
|
## Totals cache
|
|
@@ -265,11 +269,22 @@ execute:
|
|
|
265
269
|
|
|
266
270
|
An `accounting` register may also declare `analyticTotals`, a second cache keyed by the analytic slots as well: (account, cached dimensions, analytic slots, period). It covers the accounts whose chart flag `accountFlag` is true, lives in an entity of its own beside `totals` (a surrogate key, since a slot or dimension may be empty), and holds `analyticFields` (one jsonb column per slot), `accountField`, `periodField`, `period`, optional `dimensions` and `debitField`/`creditField` per resource. `getAccountBalance` / `getAccountTurnover` read it for a flagged account when every filter and grouping is a cached dimension or an analytic of the account, and take the rest from the postings. A write to the journal around posting, or a changed account flag, marks it stale until `dforge register rebuild-totals --cache analytic` (or a module install) rebuilds it. Not available on `balance`, `turnover` or `info`.
|
|
267
271
|
|
|
272
|
+
For a history loaded from another system, an `accounting` register may declare `importedKeyField`: the posting column in which a posting **loaded** beside a document posted in another system (one with no `source` stamp) keeps that document's key. `getAccountBalance` / `getAccountTurnover` then leave such postings out when `exclude` names the document, matched on that key alone, as exact text and without telling entities apart — so declare it on a text column. A document with a key of several columns cannot be matched this way and is refused. A document of an entity that posts nothing to the register is accepted and matched by the key alone. Postings the engine wrote are still matched by their stamp only. Install refuses a name that is no posting column. New in metadata 0.0.46; no shipped module uses it yet.
|
|
273
|
+
|
|
274
|
+
**Loading postings yourself.** The engine writes an analytic slot as `{ entityId, entity, entityLabel, description, pk, value }` with `entity` the qualified `module.entity` (`acc-registry.cost_center`), and the caches copy it as is. A loader writes the same qualified name. A `{ entity, value }` filter accepts either spelling: a qualified name matches its own module's slots and older bare ones, a bare name matches that code in any module. `groupBy` returns a slot as stored, so a journal still holding bare slots (written before platform #1544) beside qualified ones groups them into two rows — until a module migration rewrites the old slots and `rebuild-totals` runs.
|
|
275
|
+
|
|
276
|
+
**Rebuild time.** Install rebuilds a cache whose shape or account flags changed, in the install's transaction. On a large journal this takes minutes (about 18 minutes for an analytic cache over 1.5 million postings), and the CLI can give up waiting while the server goes on and finishes. Check the cache before installing again.
|
|
277
|
+
|
|
278
|
+
|
|
268
279
|
## Common mistakes
|
|
269
280
|
|
|
270
281
|
- **Undeclared or unqualified register.** `params.register` must be `module_cd.register_cd` and exist. Install fails, not the first posting.
|
|
271
282
|
- **Unmapped required resource.** Map it, or mark the resource `"required": false` when only some writers supply it.
|
|
272
|
-
- **Empty dimension.** An empty dimension refuses the posting. `
|
|
283
|
+
- **Empty dimension.** An empty (NULL) dimension refuses the posting; there is no `allowEmpty`, on any kind. Two ways out: map the dimension through an expression with a placeholder, `"=COALESCE([frp_id], 0)"` (`A` column only), or list it in `skipPostingWhenEmpty`, which leaves the document posted while those rows move nothing. Prefer one line table per binding. Use `skipPostingWhenEmpty` only when the distinction lives in the data, such as a serial number filled on some rows. Data loaded from another system often has such rows (a movement with no warehouse), so decide this before the load.
|
|
284
|
+
- **`headerMap` takes columns only.** It maps a register name to a **document** column; it does not take a constant or an expression. Every column it names must be in `lockedFields`: a line movement copies the header value at posting, and unposting takes back exactly what was posted, so a header value edited while posted would leave the register out of step. For a constant on every line, use a quoted constant in `map`.
|
|
285
|
+
- **One constant for two resources.** Two resources mapped to the same constant read as two resources storing into one column and are refused.
|
|
286
|
+
- **Period missing from the calendar.** With `periodEntity`, a month that has no row in the period entity counts as **closed**, and posting into it fails (`DF003: Period 1999-04 does not exist`). Seed or load the periods before the documents that post into them.
|
|
287
|
+
- **A summed column that isn't a number.** A resource of a `balance`, `turnover` or `accounting` register, and a `totals` cache's columns, must be a scalar number. The editor and language server warn (`registers/column-not-numeric`, metadata 0.0.46) on `varchar`, `integer[]` and the like. An `info` register's resources are returned, not summed, and may be any type.
|
|
273
288
|
- **Missing or unlocked `dateField`.** Required once the register has `journalDate` or `period`, and it must be in `lockedFields`.
|
|
274
289
|
- **Key over dimensions.** The journal, a subordinate `info` register's records and an `autoCreate` state table are written by the platform. Each needs **one** surrogate primary key of type `bigint` (`identity`/`cuid`) or `uuid`. A key over dimensions, a multi-column key, a text key and `int4` are refused.
|
|
275
290
|
- **State column narrower than the journal column.** It must hold the journal's value exactly, or unposting cannot take back what posting added.
|
|
@@ -162,8 +162,10 @@ $$;
|
|
|
162
162
|
- `paramCd` is capped at 50 characters, and a declared `fieldTypeCd` must be a real one —
|
|
163
163
|
both are checked at install, because binding the SP in the report editor copies them
|
|
164
164
|
into report params.
|
|
165
|
-
- `RETURNS TABLE (...)`.
|
|
165
|
+
- `RETURNS TABLE (...)`. In `LANGUAGE plpgsql` with `RETURN QUERY`, each selected column must match its declared type exactly: a `varchar` column returned as `text` fails with "structure of query does not match function result type". Cast in the select (`c.account_name::text`).
|
|
166
166
|
- Use the module's schema prefix (`crm.rpt_*`) and `STABLE` volatility.
|
|
167
|
+
- **A function that returns one row: declare `ROWS 1`.** Install adds `SET search_path` to the function, which stops Postgres from inlining it, so the planner falls back to its default estimate of 1000 rows and a join on the function can turn a 5-second report into a 20-minute one. `ROWS 1` (or a realistic estimate) on each table function the report calls restores the plan.
|
|
168
|
+
- **Every `logic/reports/*.sql` runs at every install**, as the install's database user. Don't create or replace the function by hand under another user while experimenting: the next install fails with "must be owner of function". Try SQL in a transaction you roll back, and let install own the functions.
|
|
167
169
|
- **Security is your responsibility, and the function has no context to work from**: it is not
|
|
168
170
|
told the folder or the user, so an SP dataset cannot be row-level scoped the way a `"Q"`
|
|
169
171
|
dataset is. Use `"Q"` for anything that must be filtered per user, and keep `"S"` for
|
|
@@ -197,6 +199,7 @@ granted on. Keyed by stored-procedure code, which may not contain a dot.
|
|
|
197
199
|
- **`params`** is the call signature, in order — omit it for a zero-argument function.
|
|
198
200
|
- **`columns`** is optional; without it install derives the result columns from the
|
|
199
201
|
function's `RETURNS TABLE (…)`.
|
|
202
|
+
- A result column's `baseDatatypeCd` is a platform base type code: `string`, `number`, `bool`, `date`, `timestamp`, `time`, `guid`, `json`, `link`, `set`, `binary`, `xml`, `culture`. Not `datetime`, `boolean`, `integer` or `text`: an unknown code fails install with a bare foreign-key error on `sp_column`, which validate does not catch yet.
|
|
200
203
|
- A result column may name a **`domain`** (`domain_cd` or `module_cd.domain_cd`) instead of
|
|
201
204
|
`fieldTypeCd` / `baseDatatypeCd` / `params`: a status the function returns then shows its
|
|
202
205
|
labels without restating the entity's option list. Naming a domain and restating any of
|
|
@@ -111,7 +111,7 @@ Using the converted entities, generate the full module package:
|
|
|
111
111
|
4. **`ui/menus.json`** — grouped logically (group related entities under section nodes)
|
|
112
112
|
5. **`ui/folders.json`** — root folder with all entities listed
|
|
113
113
|
6. **`security/roles.json`** — at least `module.admin` (SIUDC on everything) and `module.user` (SIU on main entities)
|
|
114
|
-
7.
|
|
114
|
+
7. **English labels in the definitions** — a short `description` on every entity, field and role, a `label` on every view, menu and action. No `translations/en-US.json`: install skips it. Translation files only for the locales in `supportedLocales` (`dforge_translation_sync`)
|
|
115
115
|
8. **`seed-data/`** — if the source had enum/lookup tables converted to entities, generate seed data for them
|
|
116
116
|
|
|
117
117
|
### Step 8: Generate IMPORT_NOTES.md
|
|
@@ -16,7 +16,7 @@ Lives in: `security/roles.json`
|
|
|
16
16
|
|
|
17
17
|
Role codes use a `module.role` naming pattern (e.g. `crm.admin`, `crm.sales-rep`).
|
|
18
18
|
|
|
19
|
-
> **Role display name & translations.** Each role has exactly two keys: `description` and `rights` (`roles.schema.json` is `additionalProperties: false` — **do not** add a `label` here). `description` is the English
|
|
19
|
+
> **Role display name & translations.** Each role has exactly two keys: `description` and `rights` (`roles.schema.json` is `additionalProperties: false` — **do not** add a `label` here). `description` is the English display name — keep it a short label ("Storekeeper"), not an explanation, since it is what the English UI shows. The **localized** display name lives in the translation files as `roles.<code>.label`, keyed by the module-qualified role code, and is **completeness-enforced**: every role must have a `roles.<code>.label` in every non-English `translations/<locale>.json` or install fails. There is no English translation file. See `translations.md`.
|
|
20
20
|
|
|
21
21
|
Example:
|
|
22
22
|
|
|
@@ -2,9 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
dForge modules support multi-language translations for user-facing labels — entity names, field labels, view titles, folder names, menu items, action labels and params, and report params/dataset captions.
|
|
4
4
|
|
|
5
|
-
Lives in: `translations/<locale>.json` (e.g. `translations/
|
|
5
|
+
Lives in: `translations/<locale>.json` for each **non-English** locale (e.g. `translations/de-DE.json`, `translations/uk-UA.json`)
|
|
6
6
|
|
|
7
|
-
Locale codes use the `ll-CC` format (language-country): `
|
|
7
|
+
Locale codes use the `ll-CC` format (language-country): `de-DE`, `uk-UA`, `fr-FR`, `es-ES`, etc.
|
|
8
|
+
|
|
9
|
+
## English is the base — and it lives in the definitions
|
|
10
|
+
|
|
11
|
+
Install **never reads** `translations/en.json` or `translations/en-*.json` (`ModulePackage.ReadTranslationFiles` skips them). The English UI shows the text written in the definitions themselves, so an English-only module ships **no** `translations/` folder, and a translated module ships one file per locale in `supportedLocales` and nothing for English. An `en-US.json` is dead weight: its labels are never applied, and the English UI keeps showing whatever the definitions say — which is how a module ends up displaying long service descriptions as labels. `dforge_module_validate` warns about such a file; `dforge_translation_sync` refuses to write one.
|
|
12
|
+
|
|
13
|
+
Where each object's English text lives (what install shows, `label ?? description ?? code` where both exist):
|
|
14
|
+
|
|
15
|
+
| Object | English text | Notes |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Entity | `description` in `entities/<e>.json` | |
|
|
18
|
+
| Field | `description` on the field | trait columns take theirs from the trait |
|
|
19
|
+
| Data view | `label`, else `description` | with both, `description` is the tooltip |
|
|
20
|
+
| Folder | `label`, else `description` | |
|
|
21
|
+
| Menu / menu item | `label`, else `description` | |
|
|
22
|
+
| Action | `label`, else `description` | an action param: its `description` |
|
|
23
|
+
| Report | `label`, else `description` | |
|
|
24
|
+
| Stored procedure | `description`; a result column: its `label` | |
|
|
25
|
+
| Print template | `description` | `label` is rejected by the schema |
|
|
26
|
+
| Setting | `description` | |
|
|
27
|
+
| Role | `description` | `label` is rejected by the schema |
|
|
28
|
+
| Column domain | `description` | |
|
|
29
|
+
| Register | `label` | |
|
|
30
|
+
|
|
31
|
+
Keep these short **labels** — "Product", "Storekeeper" — not explanations. Notes such as a legacy table name or "(in Ukrainian)" belong in docs or comments: whatever the field holds is the UI text.
|
|
8
32
|
|
|
9
33
|
## Structure
|
|
10
34
|
|
|
@@ -111,7 +135,7 @@ A check/unique constraint declares a user-facing `message` in the entity JSON (`
|
|
|
111
135
|
|
|
112
136
|
### Dropdown option labels ARE translatable (opt-in)
|
|
113
137
|
|
|
114
|
-
The `options` on a `dropdown`/`radio`/`flags` column are the **base (fallback) labels
|
|
138
|
+
The `options` on a `dropdown`/`radio`/`flags` column are the **base (fallback) labels**, and they are the English UI — write plain English there (`"Active"`), never a bilingual `"Active (Актив)"`. To translate the labels a user actually sees, add an `options` map next to the field's `label`, keyed by the option's **value** (the stored code). Prefer the **string** form (`"active": "Активний"`): the `{ label, … }` form is valid, but the VS Code extension's translation panel counts it as missing, and `module validate` does not check option translations at all. An option list several columns share belongs in a column domain, translated once under `domains.<code>.options`.
|
|
115
139
|
|
|
116
140
|
```json
|
|
117
141
|
"entities": {
|
|
@@ -204,15 +228,14 @@ Role labels are **required and localized** (this changed — older docs said rol
|
|
|
204
228
|
```
|
|
205
229
|
|
|
206
230
|
- **Keys are the role codes** from `security/roles.json` — already module-qualified (e.g. `crm.admin`, `crm.sales-rep`). They match `module_role.name`.
|
|
207
|
-
- **Value is `{ "label": ... }`** — the localized display name. It supersedes the `description` from `roles.json`
|
|
208
|
-
- **Completeness is enforced.** `TranslationCompletenessValidator` requires `roles.<code>.label` for **every** role in `security/roles.json`, in **every** translation file —
|
|
231
|
+
- **Value is `{ "label": ... }`** — the localized display name. It supersedes the `description` from `roles.json` in that locale; `description` is the English text.
|
|
232
|
+
- **Completeness is enforced.** `TranslationCompletenessValidator` requires `roles.<code>.label` for **every** role in `security/roles.json`, in **every** translation file install reads — each locale in `supportedLocales`. A missing role label fails install with `Label for role '<code>'.` English needs nothing here: it is the role's `description`.
|
|
209
233
|
|
|
210
234
|
### Sections that are NOT translated (write-time only)
|
|
211
235
|
|
|
212
|
-
- `
|
|
213
|
-
- `print_templates` — not currently consumed by the registrar. Template labels come from `ui/print_templates.json`.
|
|
236
|
+
- `print_templates` — not consumed by the registrar: a template row has no translatable resource. Its name is the `description` in `ui/print_templates.json` in every language. If you include the section, install succeeds and ignores it.
|
|
214
237
|
|
|
215
|
-
|
|
238
|
+
`settings` **is** translated: completeness is checked for each declared locale (ship `settings.<cd>.label`), and the setting's English text is its `description` in `settings.json` — the registrar never reads a `label` there.
|
|
216
239
|
|
|
217
240
|
## German example (de-DE)
|
|
218
241
|
|
|
@@ -262,7 +285,7 @@ If you include these sections, the install succeeds (they're silently ignored)
|
|
|
262
285
|
|
|
263
286
|
## Manifest declaration
|
|
264
287
|
|
|
265
|
-
Non-English locales are declared in `supportedLocales` (an array of `ll-CC` tags) — **not** a `translations` object. The manifest schema has no `translations` key and rejects it (`additionalProperties: false`). Translation files are auto-discovered at `translations/<locale>.json`.
|
|
288
|
+
Non-English locales are declared in `supportedLocales` (an array of `ll-CC` tags) — **not** a `translations` object. The manifest schema has no `translations` key and rejects it (`additionalProperties: false`). Translation files are auto-discovered at `translations/<locale>.json`. Do **not** list `en`/`en-US` in `supportedLocales` and do not ship an English file — English is the definitions' own text (see above); the schema rejects `en`/`en-US` entries.
|
|
266
289
|
|
|
267
290
|
```json
|
|
268
291
|
"supportedLocales": ["de-DE", "fr-FR"]
|
|
@@ -276,18 +299,18 @@ A result column's header belongs to the procedure, not to a report. `module pack
|
|
|
276
299
|
|
|
277
300
|
## Rules
|
|
278
301
|
|
|
279
|
-
1. **
|
|
280
|
-
2. **Locale format is `ll-CC`** (e.g. `
|
|
281
|
-
3. **Translate everything user-visible**: entity labels, every field label (including trait-provided fields like `created_date`, `last_updated`), view names, menu items, action labels, **role labels (required — completeness-enforced, `roles.<code>.label` for every role in every locale
|
|
302
|
+
1. **English is the definitions' text** — no `en-US.json`. Write a short English label into each definition (see the table above); other languages fall back to it for missing keys.
|
|
303
|
+
2. **Locale format is `ll-CC`** (e.g. `de-DE`, `uk-UA`, `fr-FR`) — language code + country code, hyphen-separated.
|
|
304
|
+
3. **Translate everything user-visible**: entity labels, every field label (including trait-provided fields like `created_date`, `last_updated`), view names, menu items, action labels, **role labels (required — completeness-enforced, `roles.<code>.label` for every role in every declared locale)**, setting labels, folder names.
|
|
282
305
|
4. **Include virtual columns** (references, sets, formulas) in field translations — they appear in the UI just like physical columns.
|
|
283
306
|
5. **Menu translations mirror the `ui/menus.json` structure** — root key → `items` → nested items.
|
|
284
|
-
6. **Missing keys fall back** to the
|
|
307
|
+
6. **Missing keys fall back** to the definition's English text, then to the raw code name. So it's safe to ship partial translations — untranslated items show in English rather than breaking.
|
|
285
308
|
7. **Don't translate column codes** — only the `label` values. Codes stay as-is in all languages.
|
|
286
309
|
8. **File naming**: `translations/<locale>.json` (e.g. `de-DE.json`) — each non-English file must match a locale listed in `supportedLocales`.
|
|
287
310
|
9. **Constraint messages are opt-in** — localize them under `entities.<e>.constraints.<c>.message` when you want a per-locale violation message; otherwise the base `message` from the entity JSON is used. Missing overrides warn (non-fatal), they don't fail install. See "Constraint violation messages ARE translatable" above.
|
|
288
311
|
|
|
289
312
|
## When to create translations
|
|
290
313
|
|
|
291
|
-
- **
|
|
292
|
-
- **On request** create additional languages
|
|
314
|
+
- **Never** create `translations/en-US.json` — install skips it. Give every definition a short English label instead, so nothing falls back to a column code.
|
|
315
|
+
- **On request** create additional languages: declare them in `supportedLocales` and run `dforge_translation_sync`, which seeds each file from the definitions' English text for you to translate in place.
|
|
293
316
|
- **Include all fields** in each language file, including trait-provided fields (`created_date`, `last_updated`, `created_by`, etc.) — users see these columns and they need labels.
|