@dforge-core/dforge-mcp 0.2.43 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +1 -1
  3. package/dist/server.js +130 -86
  4. package/package.json +3 -3
  5. package/resources/docs/conventions.md +17 -14
  6. package/resources/docs/dsl-reference.md +34 -6
  7. package/resources/schemas/entity.schema.json +14 -4
  8. package/resources/schemas/registers.schema.json +8 -3
  9. package/resources/schemas/reports.schema.json +6 -2
  10. package/resources/schemas/translations.schema.json +21 -0
  11. package/skills/dforge-mcp-author/SKILL.md +3 -2
  12. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
  13. package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
  14. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
  15. package/skills/dforge-mcp-author/references/action-dsl.md +38 -8
  16. package/skills/dforge-mcp-author/references/conventions.md +7 -1
  17. package/skills/dforge-mcp-author/references/field-types.md +3 -2
  18. package/skills/dforge-mcp-author/references/filters.md +2 -0
  19. package/skills/dforge-mcp-author/references/formulas.md +5 -1
  20. package/skills/dforge-mcp-author/references/print-templates.md +13 -2
  21. package/skills/dforge-mcp-author/references/queries.md +1 -1
  22. package/skills/dforge-mcp-author/references/registers.md +18 -3
  23. package/skills/dforge-mcp-author/references/reports.md +8 -2
  24. package/skills/dforge-mcp-author/references/schema-import.md +1 -1
  25. package/skills/dforge-mcp-author/references/security.md +1 -1
  26. package/skills/dforge-mcp-author/references/translations.md +44 -16
  27. package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
  28. package/skills/dforge-module-build/SKILL.md +10 -1
  29. package/skills/dforge-module-ship/SKILL.md +31 -3
  30. 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
@@ -503,8 +504,8 @@ Send a notification to a specific user's inbox.
503
504
  notify([approver_id], 'New purchase request from ' + [requester_name] + ' awaiting approval')
504
505
  ```
505
506
 
506
- #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?)`
507
- Two modes:
507
+ #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?, attachments?)`
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)
@@ -567,8 +568,34 @@ if (info.fileSize > 5 * 1024 * 1024) { error('Receipt larger than 5 MB') }
567
568
  if (info.mimeType != 'application/pdf') { error('Receipt must be PDF') }
568
569
  ```
569
570
 
571
+ #### `printPdf(entityCd, key, templateCd)`
572
+ 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.
573
+
574
+ ```dsl
575
+ execute:
576
+ var pdf = printPdf('fin.invoice', { invoice_id: [invoice_id] }, 'invoice')
577
+ sendEmail([customer_id].[email], 'Your invoice ' + [number], '<p>Attached.</p>', pdf)
578
+ ```
579
+
580
+ #### `downloadText(content, fileName, encoding?, mimeType?)` / `downloadBase64(base64, fileName, mimeType?)`
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()`).
582
+
583
+ ```dsl
584
+ execute:
585
+ downloadText(csv, 'payments.txt', 'windows-1251')
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
+
570
597
  #### `download(url, fileName?)`
571
- Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file.
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.
572
599
 
573
600
  ```dsl
574
601
  execute:
@@ -722,8 +749,8 @@ The first argument names a **register** (`module_cd.register_cd`, as declared in
722
749
  | `getBalance('module.register', dims?, asOf?)` | Object, per resource | Balance of an accumulation register. A dimension left out of `dims` is summed over. |
723
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. |
724
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. |
725
- | `getAccountBalance('module.register', account, filter?, asOf?)` | Object, `{debit, credit, net}` per resource | Balance of one account of an accounting register. |
726
- | `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`. |
727
754
  | `getCorrespondence('module.register', debitAccount, creditAccount, filter, from, to)` | Object, per resource | Movement between two accounts. Needs `lineMode: "compound"`; refuses an analytic filter. |
728
755
 
729
756
  An analytic filter is the key alone (`{ cost_center: 42 }`) or `{ entity, value }` to name one entity of a union kind.
@@ -1180,7 +1207,8 @@ The `audit` trait auto-maintains `last_updated` on INSERT/UPDATE if the column e
1180
1207
  | Run SQL | `query('SELECT ... WHERE c = @v', { v: value })` |
1181
1208
  | Get setting | `getSetting('setting_cd')` |
1182
1209
  | Send toast | `info('msg')`, `warn('msg')` |
1183
- | Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)` |
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 |
1211
+ | Give the user a built file | `downloadText(content, 'f.txt', 'windows-1251')` |
1184
1212
  | Send inbox notification | `notify(userId, 'msg')` |
1185
1213
  | Call a shared helper | `round2([amount])` from `logic/functions/*.dsl` |
1186
1214
  | 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
  },
@@ -418,7 +428,7 @@
418
428
  "fieldTypeCd": {
419
429
  "type": "string",
420
430
  "enum": [
421
- "text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent",
431
+ "text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent", "filesize",
422
432
  "checkbox", "date", "datetime", "time",
423
433
  "dropdown", "flags", "tags", "json", "hidden", "color", "image", "file",
424
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,
@@ -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.",
@@ -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`, `conventions`.
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,5 +1,5 @@
1
1
  {
2
- "description": "Products, with stock and pricing on one record",
2
+ "description": "Product",
3
3
  "dbObject": "product",
4
4
  "toString": "{name}",
5
5
  "traits": ["identity", "audit"],
@@ -14,7 +14,6 @@
14
14
  "dependencies": {
15
15
  "admin": ">=0.0.1"
16
16
  },
17
- "supportedLocales": ["en-US"],
18
17
  "created": "2026-08-19",
19
18
  "updated": "2026-08-19",
20
19
  "entities": {
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "column_security.storekeeper": {
3
- "description": "Counts stock; never sees cost or price",
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": "Owns costing and pricing",
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`) accepts a qualified `module.entity` code — write `update('comm.message', …)`,
242
- > not `update('message', …)`, even inside the owning module. It reads unambiguously and keeps the
243
- > script correct if it's ever moved to a bridge module.
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
@@ -249,6 +252,14 @@ var note = params[note] // no quotes around param name in this DSL
249
252
  > `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
250
253
  > loop and mutate per id instead.
251
254
 
255
+ > **Posting state.** `update()` setting a register posting state (the `A` / `L` column) posts or unposts
256
+ > the document through the register engine, in a trigger action and in a background run alike; a refused
257
+ > posting rolls the caller's write back. A background run holds no transaction, so such a call opens its
258
+ > own: atomic per call, not per run. A scheduled run cannot post with `update()`.
259
+
260
+ > **Keys of any type.** A field saved with `[field] = value` binds the record key by the key column's own
261
+ > type, so a `uuid` or a digits-only `varchar` key works.
262
+
252
263
  > **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
253
264
  > write layer: by design they skip folder row-filters, closed-period checks and registry state, and
254
265
  > they raise no trigger or webhook events either. Only the **acted-on record's own**
@@ -311,12 +322,14 @@ The first argument names a **register** (`module_cd.register_cd`, as declared in
311
322
  | `getBalance('module.register', dims?, asOf?)` | Object, per resource | Balance of an accumulation register. A dimension left out of `dims` is summed over. |
312
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. |
313
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. |
314
- | `getAccountBalance('module.register', account, filter?, asOf?)` | Object, `{debit, credit, net}` per resource | Balance of one account of an accounting register. |
315
- | `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`. |
316
327
  | `getCorrespondence('module.register', debitAccount, creditAccount, filter, from, to)` | Object, per resource | Movement between two accounts. Needs `lineMode: "compound"`; refuses an analytic filter. |
317
328
 
318
329
  An analytic filter is the key alone (`{ cost_center: 42 }`) or `{ entity, value }` to name one entity of a union kind.
319
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
+
320
333
  ### Messaging
321
334
 
322
335
  | Function | Description |
@@ -339,7 +352,18 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
339
352
  | Function | Description |
340
353
  |---|---|
341
354
  | `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). |
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. |
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. |
357
+
358
+ ### PDF and file output
359
+
360
+ | Function | Returns | Description |
361
+ |---|---|---|
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. |
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. |
366
+ | `downloadBase64(base64, fileName, mimeType?)` | URL string | `downloadText()` for bytes; `application/octet-stream` unless `mimeType` says otherwise. |
343
367
 
344
368
  ### External API Calls
345
369
 
@@ -350,7 +374,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
350
374
  | `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
375
  | `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
352
376
  | `getFileInfo(fileFieldValue)` | Object \| null | File metadata for a file field. |
353
- | `download(url, fileName?)` | — | Fetch a URL into tenant storage. |
377
+ | `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
378
  | `callService('name', { args })` | Any | Invoke a registered host service. |
355
379
 
356
380
  ### Files, settings, document extraction
@@ -683,6 +707,8 @@ In `batch` mode, access individual records with `__records.get(i)`, the count wi
683
707
 
684
708
  ## Async actions
685
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
+
686
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.
687
713
 
688
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.
@@ -729,6 +755,8 @@ In `ui/actions.json`:
729
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 |
730
756
  | `orderNum` | Recommended | Display order in the action menu |
731
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 |
732
760
 
733
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.
734
762
 
@@ -792,7 +820,7 @@ be either.
792
820
  > and `ON COMMIT DROP` on any temp table. Only `query()` can do any of this — the
793
821
  > structured built-ins emit plain DML — and nothing checks it statically.
794
822
 
795
- 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. (Module codes with a hyphen, e.g. `crm-fin`, cannot be schema-qualified by hand in raw SQL; use the structured built-ins, which accept `module.entity`.)
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.)
796
824
  2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
797
825
  3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
798
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.
@@ -808,6 +836,8 @@ be either.
808
836
  - Passing an **array of ids** as the `key` of `update()`/`delete()` — **rejected**. Loop over the ids and mutate one at a time.
809
837
  - Selecting or string-filtering a plain reference (`R`) column in `select()` — **throws**. Use the FK column or a nav path (`'customer.company_name'`).
810
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.
811
841
  - Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
812
842
  - Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
813
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. The installer may reject or silently skip a re-install with the same version.
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
 
@@ -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" }`.
@@ -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]`.
@@ -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:
@@ -88,7 +94,9 @@ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does
88
94
  | `format_date: <strftime>` | `{{ invoice_date | format_date: '%B %d, %Y' }}` | `"May 22, 2026"` |
89
95
  | `qr_code: <size?>, <ecc?>, <margin?>, <format?>`| `{{ payment_url | qr_code: 200, 'M' }}` | `data:image/png;base64,…` |
90
96
 
91
- `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.
92
100
 
93
101
  ## Snippets
94
102
 
@@ -170,6 +178,9 @@ my_module/
170
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"`).
171
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.
172
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.
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.
183
+ - **Dashed comment tags.** The server cuts every `{%- comment -%}` block before parsing, but keep snippets to plain `{% comment %}` so both engines agree.
173
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.
174
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).
175
186
  - **Printing an unsorted set.** Rows come in primary-key order; use `| sort: 'col'` (and `| reverse`) in the template.
@@ -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/en-US.json`:
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. `skipPostingWhenEmpty` is the exception: it leaves a document posted while some lines moved nothing. Prefer one line table per binding. Use it only when the distinction lives in the data, such as a serial number filled on some rows.
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.