@dforge-core/dforge-mcp 0.2.48 → 0.2.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,14 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
- ## Unreleased
7
+ ## 0.2.49 - 2026-11-03
8
+
9
+ **Query columns (`columnType: "Q"`).** A column that gives a record a read-only list of rows — a balance register on the
10
+ record's date, or a query written in the column and filled from the record, optionally shown as a report visualization.
11
+ It is shown on the card and read by print forms, with the reader's rights (core #1584). `column-types.md` gains the
12
+ section and the install refusals; `print-templates.md`, `data-views.md`, `reports.md` and `registers.md` say how it
13
+ meets each. `dforge_entity_field_add` accepts `columnType: "Q"` (requires `params`, no `dbDatatype`). The vendored
14
+ entity schema and `@dforge-core/metadata` (^0.0.49) carry it. The model editor cannot add one yet.
8
15
 
9
16
  **1C migration readiness and regressions.** `delta --apply` now reports per-module
10
17
  `ready` / `partial` status and exits 2 if state, totals or imported markers remain
package/dist/server.js CHANGED
@@ -31243,7 +31243,7 @@ function buildZedTasks() {
31243
31243
  var fs = __toESM(require("fs"));
31244
31244
  var path = __toESM(require("path"));
31245
31245
 
31246
- // node_modules/.pnpm/@dforge-core+metadata@0.0.47/node_modules/@dforge-core/metadata/dist/index.js
31246
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.49/node_modules/@dforge-core/metadata/dist/index.js
31247
31247
  var AggType = {
31248
31248
  Sum: "sum",
31249
31249
  Count: "count",
@@ -31321,6 +31321,7 @@ var columnTypes = [
31321
31321
  { cd: "F", name: "Formula", description: "A virtual value calculated from a formula expression." },
31322
31322
  { cd: "G", name: "Generated", description: "A physical column auto-maintained by the database (aggregate or expression)." },
31323
31323
  { cd: "A", name: "Accumulation register", description: "Posts this document into a balance entity (single direction)." },
31324
+ { cd: "Q", name: "Query rows", description: "A read-only list of rows this record reads: a register's balance as of its date, or a query filled from its columns." },
31324
31325
  { cd: "L", name: "Ledger register", description: "Posts document lines into movements + balances (double entry)." }
31325
31326
  ];
31326
31327
  var byCd2 = new Map(columnTypes.map((ct) => [ct.cd, ct]));
@@ -33428,7 +33429,7 @@ function checkFieldSpec(where, field) {
33428
33429
  );
33429
33430
  }
33430
33431
  if (typeof v.flags === "string" && v.flags.includes("M") && v.isNullable === true) {
33431
- const virtual = typeof v.columnType === "string" && ["R", "S", "F"].includes(v.columnType);
33432
+ const virtual = typeof v.columnType === "string" && ["R", "S", "F", "Q"].includes(v.columnType);
33432
33433
  err(
33433
33434
  `flags '${v.flags}' includes 'M' (mandatory) while "isNullable": true \u2014 a contradiction that fails at pack time. 'M' is resolved into isNullable:false at install. Drop 'M' if the column is optional, or remove "isNullable": true if it is required.${virtual ? " (On a virtual column 'M' is inert anyway \u2014 drop it.)" : ""}`
33434
33435
  );
@@ -33443,7 +33444,7 @@ function checkFieldSpec(where, field) {
33443
33444
  const columnType = typeof v.columnType === "string" ? v.columnType : "";
33444
33445
  if (columnType.length > 0 && !getColumnType(columnType)) {
33445
33446
  err(
33446
- `columnType '${columnType}' is invalid. Use 'R' (reference), 'S' (set/child list), or 'F' (formula) \u2014 or omit it for a plain data column. (A/L/G exist for register columns.)`
33447
+ `columnType '${columnType}' is invalid. Use 'R' (reference), 'S' (set/child list), 'F' (formula) or 'Q' (a list of rows the record reads) \u2014 or omit it for a plain data column. (A/L/G exist for register columns.)`
33447
33448
  );
33448
33449
  }
33449
33450
  if (typeof v.dbDatatype === "string" && v.dbDatatype.length > 0) {
@@ -33477,6 +33478,21 @@ function checkFieldSpec(where, field) {
33477
33478
  );
33478
33479
  }
33479
33480
  }
33481
+ if (columnType === "Q") {
33482
+ if (v.params === void 0 || typeof v.params !== "object" || v.params === null) {
33483
+ err(
33484
+ "query column (columnType 'Q') requires 'params': { register, asOf, dimensions? } or { entity, query, params? }. (See dforge://reference/column-types.)"
33485
+ );
33486
+ }
33487
+ if (v.dbDatatype !== void 0) {
33488
+ err("query column (columnType 'Q') must NOT set 'dbDatatype' \u2014 it owns no physical column.");
33489
+ }
33490
+ if (typeof v.flags === "string" && /[EM]/.test(v.flags)) {
33491
+ warn(
33492
+ `query column has flags '${v.flags}' \u2014 a list the record reads is never edited or required; the platform ignores them.`
33493
+ );
33494
+ }
33495
+ }
33480
33496
  if (columnType === "G") {
33481
33497
  if (typeof v.formula !== "string" || v.formula.trim() === "") {
33482
33498
  err("generated column (columnType 'G') requires a 'formula' expression.");
@@ -34612,7 +34628,7 @@ var path10 = __toESM(require("path"));
34612
34628
  var fs7 = __toESM(require("fs"));
34613
34629
  var path9 = __toESM(require("path"));
34614
34630
 
34615
- // node_modules/.pnpm/@dforge-core+metadata@0.0.47/node_modules/@dforge-core/metadata/dist/dsl/index.js
34631
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.49/node_modules/@dforge-core/metadata/dist/dsl/index.js
34616
34632
  var PUNCT3 = ["===", "!=="];
34617
34633
  var PUNCT2 = [
34618
34634
  "==",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.48",
3
+ "version": "0.2.49",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@dforge-core/dforge-cli": "^0.2.37",
30
- "@dforge-core/metadata": "^0.0.47",
30
+ "@dforge-core/metadata": "^0.0.49",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^4.4.3"
33
33
  },
@@ -345,11 +345,90 @@
345
345
  },
346
346
  "additionalProperties": false
347
347
  },
348
+ "queryColumnParams": {
349
+ "type": "object",
350
+ "description": "Binding of a query column (columnType Q): a read-only list of rows for the record, with no physical column. It reads either a balance register as of the record's date (register, asOf, dimensions) or a query written in the column and filled from the record (entity, query, params). Shown on the card and read by print forms, with the reader's rights.",
351
+ "oneOf": [
352
+ {
353
+ "required": ["register", "asOf"],
354
+ "properties": {
355
+ "register": {
356
+ "type": "string",
357
+ "description": "A balance register of this module or of one it depends on, as 'module_cd.register_cd'. It needs a journal that stamps the date and the direction of each movement.",
358
+ "examples": ["wms-registry.stock"]
359
+ },
360
+ "asOf": {
361
+ "type": "string",
362
+ "description": "Date (or timestamp) column of this entity the balance is taken on. The day itself is included."
363
+ },
364
+ "viz": {
365
+ "type": "object",
366
+ "description": "How the card shows the rows: a report visualization by its type (table, a chart, pivot...) and its config as that visualization takes it. Unset: a table. A print form reads the rows as a table whatever this says.",
367
+ "required": ["type"],
368
+ "properties": {
369
+ "type": { "type": "string" },
370
+ "config": { "type": "object" }
371
+ },
372
+ "additionalProperties": false
373
+ },
374
+ "dimensions": {
375
+ "type": "object",
376
+ "description": "Register dimension → column of this entity that narrows it. A dimension left out is not narrowed, so its tuples all come back. A narrowing column that is empty on the record leaves the list empty.",
377
+ "additionalProperties": { "type": "string" },
378
+ "examples": [{ "warehouse_id": "warehouse_id" }]
379
+ }
380
+ },
381
+ "additionalProperties": false
382
+ },
383
+ {
384
+ "required": ["entity", "query"],
385
+ "properties": {
386
+ "entity": {
387
+ "type": "string",
388
+ "description": "The entity the query reads, as 'module_cd.entity_cd' (a bare code is this module's own). Of this module or of one it depends on.",
389
+ "examples": ["retail.goods_line"]
390
+ },
391
+ "query": {
392
+ "type": "object",
393
+ "description": "The query itself, in the shape query.run takes: columns (a plain name, or { c, agg } with agg group / sum / count / avg / min / max — grouped if any column has one), filter and sort. A filter value written '@name' is filled from the record through 'params'. An aggregate is read under its column's code. Written here and not saved elsewhere, so no edit of a shared query can change the form.",
394
+ "properties": {
395
+ "columns": { "type": "array" },
396
+ "filter": {},
397
+ "sort": { "type": "array" }
398
+ },
399
+ "additionalProperties": false
400
+ },
401
+ "viz": {
402
+ "type": "object",
403
+ "description": "How the card shows the rows: a report visualization by its type (table, a chart, pivot...) and its config as that visualization takes it. Unset: a table. A print form reads the rows as a table whatever this says.",
404
+ "required": ["type"],
405
+ "properties": {
406
+ "type": { "type": "string" },
407
+ "config": { "type": "object" }
408
+ },
409
+ "additionalProperties": false
410
+ },
411
+ "params": {
412
+ "type": "object",
413
+ "description": "Query parameter (the name after '@' in a filter value) → stored column of this entity that fills it. Every @param of the query is listed, and only those. A column that is empty on the record leaves the list empty rather than widening the query.",
414
+ "additionalProperties": { "type": "string" },
415
+ "examples": [{ "doc": "document_id" }]
416
+ }
417
+ },
418
+ "additionalProperties": false
419
+ }
420
+ ]
421
+ },
348
422
  "registryColumnParams": {
349
423
  "type": "object",
350
424
  "description": "params of an accumulation (A) or ledger (L) registry column. The lock lists are what BOTH generated guards freeze — the posted-document guard while the document is posted, the period guard while its period is closed — and they are required: install refuses a registry column that omits or empties them. The header's periodDateField and the state column are watched in addition, whatever the list says.",
351
425
  "required": ["lockedFields"],
426
+ "if": { "required": ["editorMayPost"], "properties": { "editorMayPost": { "const": true } } },
427
+ "then": { "not": { "anyOf": [ { "required": ["postAction"] }, { "required": ["unpostAction"] } ] } },
352
428
  "properties": {
429
+ "postAction": { "type": "string", "minLength": 1, "description": "Code of the action (available for this entity) that posts the document. A direct data.insert / data.update that turns the state flag on needs 'E' on it, as the button does, so \"may edit\" and \"may post\" stay two rights through the API too. An action's own writes are not re-checked. Pack refuses a name that no action declared for this entity carries. Unset: the transition needs only Insert/Update." },
430
+ "unpostAction": { "type": "string", "minLength": 1, "description": "Code of the action that unposts the document; the same gate for a direct write that turns the state flag off." },
431
+ "editorMayPost": { "type": "boolean", "description": "Declares that Insert/Update on the document includes posting it: no separate right to post is intended, so no postAction / unpostAction is named. Changes nothing at run time; it silences the pack warning for a column that names no posting action. Not together with postAction / unpostAction." },
353
432
  "lockedFields": {
354
433
  "type": "array",
355
434
  "minItems": 1,
@@ -408,6 +487,10 @@
408
487
  {
409
488
  "if": { "required": ["columnType"], "properties": { "columnType": { "enum": ["A", "L"] } } },
410
489
  "then": { "required": ["params"], "properties": { "params": { "$ref": "#/$defs/registryColumnParams" } } }
490
+ },
491
+ {
492
+ "if": { "required": ["columnType"], "properties": { "columnType": { "const": "Q" } } },
493
+ "then": { "required": ["params"], "properties": { "params": { "$ref": "#/$defs/queryColumnParams" } } }
411
494
  }
412
495
  ],
413
496
  "properties": {
@@ -422,8 +505,8 @@
422
505
  },
423
506
  "columnType": {
424
507
  "type": "string",
425
- "enum": ["D", "R", "S", "F", "A", "L", "G"],
426
- "description": "Column type: D=Data (physical column), R=Reference (N:1 lookup), S=Set (1:N collection), F=Formula (virtual calculated), G=Generated/Computed (physical, auto-maintained), A=Accumulation registry, L=Ledger registry"
508
+ "enum": ["D", "R", "S", "F", "A", "L", "G", "Q"],
509
+ "description": "Column type: D=Data (physical column), R=Reference (N:1 lookup), S=Set (1:N collection), F=Formula (virtual calculated), G=Generated/Computed (physical, auto-maintained), A=Accumulation registry, L=Ledger registry, Q=Query (virtual list of rows read from a register, bound to this record)"
427
510
  },
428
511
  "fieldTypeCd": {
429
512
  "type": "string",
@@ -633,6 +716,17 @@
633
716
  "columnType": { "const": "G" },
634
717
  "link": false
635
718
  }
719
+ },
720
+ {
721
+ "title": "Query column (Q)",
722
+ "description": "Read-only list of rows the record reads, with no physical column (columnType 'Q'): a register's balance as of the record's date, or a query written in 'params' and filled from the record. Requires 'params'. Must NOT have 'dbDatatype', 'fieldTypeCd' or 'link'.",
723
+ "required": ["columnType", "params"],
724
+ "properties": {
725
+ "columnType": { "const": "Q" },
726
+ "dbDatatype": false,
727
+ "fieldTypeCd": false,
728
+ "link": false
729
+ }
636
730
  }
637
731
  ],
638
732
  "required": ["orderNum", "description"],
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "idempotencyKey": {
54
54
  "type": "string",
55
- "description": "Template like '{job_cd}:{run.month}'. Resolved at fire time. A successful run with the same key blocks re-dispatch (Phase 3 enforcement)."
55
+ "description": "Reserved; not enforced yet. Stored but never read by the scheduler, so it does not block re-dispatch. Only the scheduled slot (job, scheduled_at) is deduplicated; guard business-level 'once per period' inside the action."
56
56
  },
57
57
  "class": {
58
58
  "type": "string",
@@ -55,6 +55,11 @@
55
55
  "left": { "type": "string" }
56
56
  },
57
57
  "additionalProperties": false
58
+ },
59
+ "culture": {
60
+ "type": "string",
61
+ "description": "The form's own culture, e.g. 'uk-UA': format_number, format_currency, format_date, _fmt and Yes/No print in it whoever prints the form. Omitted: the user's culture. An unknown culture is refused at pack and install.",
62
+ "examples": ["uk-UA", "de-CH"]
58
63
  }
59
64
  },
60
65
  "additionalProperties": false
@@ -1,6 +1,6 @@
1
1
  # Column Types Reference
2
2
 
3
- Every column has a `columnType` (optional for the default physical type). Seven values exist:
3
+ Every column has a `columnType` (optional for the default physical type). Eight values exist:
4
4
 
5
5
  | `columnType` | Physical DB column? | Description |
6
6
  |---|---|---|
@@ -10,9 +10,10 @@ Every column has a `columnType` (optional for the default physical type). Seven
10
10
  | `"F"` | No | **Formula column** — virtual computed value. Evaluated by the formula engine. |
11
11
  | `"A"` | Yes | **Accumulation register column** — stores posted state for accumulation registers. Advanced (accounting modules). |
12
12
  | `"L"` | Yes | **Ledger register column** — stores posted state for double-entry bookkeeping. Advanced (accounting modules). |
13
+ | `"Q"` | No | **Query column** — a read-only list of rows the record reads (a register's balance on its date, or a query filled from its columns). Shown on the card and read by print forms. See below. |
13
14
  | `"G"` | Yes | **Generated column** — DB-level computed value. Two strategies, auto-detected from the formula: a **trigger-based aggregate over a child set** (`SUM([lines].[amount])`), or a same-row PostgreSQL `GENERATED ALWAYS AS` (`[qty] * [price]`). This is the correct type for **roll-up totals over child rows** (see below). The aggregated child column **must be physical** (a `D` column, or a same-row `G`) — never a virtual `F`/`R`/`S` column. |
14
15
 
15
- **For most module development, you'll use D, R, S, and F.** Add `G` when you need a **roll-up total over a child set** (`SUM`/`COUNT`/`AVG`/`MIN`/`MAX`) — see below. Types A and L are for advanced accounting/registry modules that use the `postable`, `accumulation`, or `ledger` traits.
16
+ **For most module development, you'll use D, R, S, and F.** Add `Q` for a list a form reads from elsewhere. Add `G` when you need a **roll-up total over a child set** (`SUM`/`COUNT`/`AVG`/`MIN`/`MAX`) — see below. Types A and L are for advanced accounting/registry modules that use the `postable`, `accumulation`, or `ledger` traits.
16
17
 
17
18
  ## Data columns (`columnType` omitted or `"D"`)
18
19
 
@@ -165,6 +166,40 @@ Do **not** include `dbDatatype` on formula columns (there's no physical storage)
165
166
 
166
167
  See `formulas.md` for syntax.
167
168
 
169
+ ## Query columns (`columnType: "Q"`)
170
+
171
+ A read-only list of rows a record reads, with no physical column and no `link`: the card shows it beside the sets, and a print form loops it like a set. Declare it in the entity file; the model editor cannot add one. The binding is `params`, one of two sources:
172
+
173
+ ```json
174
+ // A balance register on the record's date, narrowed by the record's own columns
175
+ "old_stock": {
176
+ "columnType": "Q",
177
+ "description": "Balance on the document's date",
178
+ "params": { "register": "wms-registry.stock", "asOf": "doc_date", "dimensions": { "warehouse_id": "warehouse_id" } }
179
+ },
180
+ // A query written in the column, its @param filled from the record; grouped and summed if the query is
181
+ "lines_by_product": {
182
+ "columnType": "Q",
183
+ "description": "Lines by product",
184
+ "params": {
185
+ "entity": "retail.document_line",
186
+ "query": {
187
+ "columns": [ { "c": "product_id", "agg": "group" }, { "c": "quantity", "agg": "sum" } ],
188
+ "filter": { "c": "document_id", "o": "eq", "v": "@doc" },
189
+ "sort": [ { "c": "product_id", "d": "asc" } ]
190
+ },
191
+ "params": { "doc": "document_id" }
192
+ }
193
+ }
194
+ ```
195
+
196
+ - `register` is a `balance` register of this module or one it depends on, with a journal that stamps date and direction. `asOf` is a date column of the record (the day itself is included). `dimensions` maps a register dimension to a record column; a dimension left out is not narrowed.
197
+ - `entity` is `module.entity` (bare for this module's own). `query` takes `columns`, `filter` and `sort` as `query.run` does. Every `@param` of the filter is listed in `params.params`, to a stored column of the record, and only those. State a `sort` on a grouped query. An aggregate is read under its column's code.
198
+ - Optional `params.viz` (`{ "type": "bar", "config": {} }`) shows the card's rows as a report visualization; unset, a table. A print form reads a table whatever it says.
199
+ - Reads use the **reader's** rights: Select on the source, the folder's view and row filter. A reader who may not see it gets the column left out of the card and an empty list with a warning in a print; a PDF or subscription is refused. A record column that is empty gives an empty list, never every row. At most 1000 rows.
200
+ - **Install refuses**: an undeclared register or entity (or one of a module this one does not depend on), a register that is not `balance` or has no dated journal, `asOf` that is not a date, a column the record or the source does not have, an unfilled or extra `@param`, keys of the other source, a `viz` without `type`, a Q column in an extension of another module's entity, a Q column listed in a data view, and a Q column as a report parameter source.
201
+ - A Q column is no field: `data.get` skips it, DSL `select()` refuses it, and it cannot be edited. For a figure over a large table use a `G` column or a record report.
202
+
168
203
  ## Roll-up totals over child rows — use `G`, not `F`
169
204
 
170
205
  To total a child set on a header (line items → order total, movements → quantity on hand),
@@ -213,5 +248,6 @@ inside one aggregate must point at the **same** set column, and `COUNT(*)` is re
213
248
  - **"User picker" (points to admin.user)** → omit `columnType`, use `fieldTypeCd: "user"` (writes user ID directly, no paired FK)
214
249
  - **"Polymorphic link to any entity"** → omit `columnType`, use `fieldTypeCd: "entitylink"` (stored as JSON)
215
250
  - **"Roll-up total over child rows"** (line items → header total, movements → quantity on hand) → `"G"` with `SUM([set].[field])` (trigger-maintained, stored; see "Roll-up totals" above). Aggregate only a **physical** child column — never an `F`/`R`/`S` column. Do **not** use `"F"` for set aggregation — it silently renders empty.
251
+ - **"List of rows this record reads"** (stock on the document's date, a document's lines by product) → `"Q"` (see "Query columns" above)
216
252
  - **"Accumulation register state"** → `"A"` (advanced accounting — requires `postable` + `accumulation` traits)
217
253
  - **"Double-entry ledger state"** → `"L"` (advanced accounting — requires `postable` + `ledger` traits)
@@ -273,6 +273,8 @@ Each column is either a simple string or an object:
273
273
  ]
274
274
  ```
275
275
 
276
+ A query column (`columnType: "Q"`) is a list the record reads, not a value: a view that lists one is refused at validate/pack. It shows on the record's card instead (column-types.md § Query columns).
277
+
276
278
  ## Filters
277
279
 
278
280
  Filters use dForge's canonical JSON filter format — see [filters.md](filters.md) for the full grammar. Keys are `c` (column), `o` (operator), `v` (value) for conditions, and `g` (group operator), `i` (items) for groups.
@@ -167,6 +167,19 @@ The whole date family evaluates identically **client-side and SQL-time** (report
167
167
  `dforge_module_validate` and `dforge_module_pack` (the CLI's static checks) catch only a
168
168
  malformed call, such as a name that is not a text literal or a dimension without its value;
169
169
  whether the register, resource and dimensions exist is known at install.
170
+ - The resource and dimensions must be stored columns of the register's entity. Install
171
+ refuses a register whose dimension or resource is a formula, reference or set column:
172
+ nothing is stored there to read. A register installed before that rule gives an empty
173
+ cell with a warning.
174
+ - A resource or dimension name may follow ONE reference (#1585). This is 1C's
175
+ `СрезПоследних(...).ФизЛицо.Наименование`. As the resource, `'person_id.name'` reads `name`
176
+ of what the record in effect references. As a dimension, `'employee_id.person_id', [person_id]`
177
+ matches the records whose employee is that person's; the newest across them wins. The column
178
+ followed must be the `thisKey` of a single-key reference column on the register's entity, and
179
+ the column named must be stored on the target's own table. The target is read with the user's
180
+ rights (Select, visible columns, row filter). A target the user cannot see gives an empty
181
+ cell, never an older record. Install refuses a path through a non-reference or to an unstored
182
+ column; pack refuses two hops.
170
183
  - Refused at install in a `G` column: it is computed when a row is written and would not
171
184
  follow a record added to the register later. Use an `F` column.
172
185
  - Newest record only: there is no `first` mode as `getSlice` has. Each row is one index
@@ -421,7 +434,8 @@ An `F` column is virtual: not stored, not editable. To fill a **stored, editable
421
434
  - Using JavaScript syntax like `row.field` or `this.field` — **wrong**. Only `[field]`.
422
435
  - Forgetting `baseDatatypeCd` on formula columns — **required**. Without it, filters and SQL don't work.
423
436
  - Using SQL syntax like `SELECT`, `JOIN`, `WHERE` — **wrong**. Formulas are expressions, not queries.
424
- - String concatenation with commas — **wrong**. Use `+` or `CONCAT()`.
437
+ - String concatenation with commas — **wrong**. Use `+` or `CONCAT()`. `+` concatenates when either side is text: a text column or literal, a string function, an `IF` whose branch is text, `COALESCE` / `NULLIF` of text, or a `SLICE()` of a text resource. `CONCAT()` also reads a null as empty, so prefer it when a part may be missing.
438
+ - `[x] != ''` is true when `[x]` is null: comparisons are null-safe. To test "has text", write `COALESCE([x], '') != ''`.
425
439
 
426
440
  ## Reference
427
441
 
@@ -63,12 +63,15 @@ Every column on the entity is a top-level variable. Display formatting is pre-ap
63
63
  | `{{ _settings.<cd> }}` | Folder-resolved module setting. Image/file settings come as base64 data URIs (drop into `<img src>`). |
64
64
  | `{{ _today }}` | Typed `DateTimeOffset` (UTC); `{{ _fmt._today }}` is the pre-formatted string |
65
65
  | `{{ <set_name> }}` | Child set — array, in primary-key order. Loaded wherever the template's Liquid code names it (see below) |
66
+ | `{{ <query_column> }}` | Query column (`Q`) — the rows the record reads (`{% for r in old_stock %}{{ r.quantity }} {{ r._fmt.quantity }}`). Loaded only where the template's Liquid code names it, like a set; always a table here, whatever the column's `viz` says |
66
67
  | `{{ _css }}` | The CSS string attached to the template (rarely needed in template body) |
67
68
 
68
69
  Inside a child-set loop, the same shape applies per row: `line._fmt.<col>`, `line._raw.<col>`, `line._color.<col>`.
69
70
 
70
71
  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
 
73
+ A query column's rows are read with the **printer's** rights: without Select on its source the list prints empty and `print.render` says so in `warnings`, while `print.renderPdf`, `printPdf()` and a subscription are refused with 403 (a PDF that is archived or mailed must not silently miss data). A record with an empty date or narrowing column lists nothing, with a warning. The default form (no template) lists no query column. See column-types.md § Query columns.
74
+
72
75
  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
76
 
74
77
  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.
@@ -94,13 +97,14 @@ A value that changes over time — a price, an employee's position, an organizat
94
97
  | A value of the record or of a record it references (the organization's requisites as one block) | An `F` column on the entity: references and built-in functions |
95
98
  | A value per line (places, weight through the product's units) | An `F` column on the line entity, printed as `line.<col>` |
96
99
  | One total over a set | Over a stored line column (`D`, or a `G` on the line): a `G` column on the document (`SUM([lines].[amount])`). Over a line `F` column: a `plus` accumulator in the loop, `{% assign s = 0 %}{% for l in lines %}{% assign s = s \| plus: l.places %}{% endfor %}`, since a `G` cannot sum an `F` child (install fails with `column old.<field> does not exist`) |
97
- | Lines grouped by a column, totals per group (by product and series, by VAT rate, a packing list by pallet) | **Not available in the print context yet.** It prints the record and its sets as stored |
100
+ | Lines grouped by a column, totals per group (by product and series, by VAT rate, a packing list by pallet) | A `Q` column with a grouped query on the line entity (`group` on the column, `sum` on the amounts, `@doc` filled from the record), printed as `{% for r in lines_by_product %}` (column-types.md § Query columns) |
101
+ | A register's balance on the document's date, per product | A `Q` column with `register` / `asOf` / `dimensions`, printed as `{% for r in old_stock %}` |
98
102
 
99
- For the last row, do not work around it:
103
+ For grouped rows, use a `Q` column; do not work around it:
100
104
 
101
105
  - **Not in Liquid filters.** `where`, `sum`, `group_by` and `where_exp` run in the LiquidJS preview, but Scriban (the print) fails with "The function `where` was not found". Filter and total with `{% if %}` and `plus` inside a `for`, which both engines run.
102
106
  - **Not a print table filled by an action.** It stores a copy of the document that drifts from it.
103
- - **Not a stored procedure.** The grouping itself is a grouped query (`references/queries.md`): `group` on the column, `sum` over a line `F` column.
107
+ - **Not a stored procedure.** The grouping itself is a grouped query (`references/queries.md`) held in the `Q` column: `group` on the column, `sum` over the amounts.
104
108
 
105
109
  ## Custom filters
106
110
 
@@ -111,7 +115,7 @@ For the last row, do not work around it:
111
115
  | `format_date: <strftime>` | `{{ invoice_date | format_date: '%B %d, %Y' }}` | `"May 22, 2026"` |
112
116
  | `qr_code: <size?>, <ecc?>, <margin?>, <format?>`| `{{ payment_url | qr_code: 200, 'M' }}` | `data:image/png;base64,…` |
113
117
 
114
- `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.
118
+ `format_date` supports strftime: `%Y %y %m %d %e %B %b %A %a %H %I %M %S %p` and `%%`. A dash drops the leading zero: `%-m %-d %-H %-I %-M %-S` (`'%-H:%M'` → `9:05`). Text around the specifiers prints as written (`'%d.%m.%Y г.'`). Month and weekday names follow the form's culture (`pageSettings.culture`, else the user's). `%B` beside a day of the month is the genitive (`березня`). `format_date` returns a **string**: compute on the raw value and format last.
115
119
 
116
120
  **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.
117
121
 
@@ -148,7 +152,7 @@ Snippets run on Scriban (print) and LiquidJS (preview), so write them for both:
148
152
  - No `floor` on big numbers: Scriban's goes through a double and rounds them up.
149
153
  - No `modulo: 1` on a fraction without snapping the whole part with `round`. LiquidJS 10.30 leaves float noise: `0.44 | modulo: 1` is 0.43999999999999995.
150
154
  - Round a fraction half up on its decimal digits, read from the number's text (`append: ''`; the server may print a comma; a browser double under 1e-6 prints as `5e-7`), never by `times` / `plus: 0.5` / `floor`. A double's text is the shortest decimal it stands for, so this matches the server's decimals both on a tie (960.015 → 02) and just below one (1000000000.0049999 → 00). No epsilon can do both.
151
- - No number taken from `split` / `slice`. A digit is read as `'x0123456789' | split: ch | first | size | minus: 1`.
155
+ - A number taken from `split` / `slice` is a string. `plus`, `minus`, `times`, `divided_by` and `modulo` take a numeric string as its number on both engines: `'5' | plus: 0` is `5`, where before platform 2026-10-30 Scriban printed `50`. A list answers `.first` / `.last` as well as `| first`.
152
156
  - Plain `{% comment %}` without trim dashes.
153
157
 
154
158
  ## Module package layout
@@ -256,6 +256,7 @@ execute:
256
256
  var price = getSlice("wms-registry.outlet_price", { warehouse_id: [warehouse_id], product_id: [product_id] }, [order_date])
257
257
  ```
258
258
 
259
+ - A form that lists what a balance register held on the record's date (a revaluation's old stock) needs no action: a `Q` column (`register`, `asOf`, `dimensions`; column-types.md § Query columns) reads it with the reader's rights.
259
260
  - A dimension left out is summed over by `getBalance` and `getTurnover`. `getSlice` refuses an omitted dimension.
260
261
  - `getSlice` answers one dimension tuple from an action. A value per row — each line's price on its document's date, in a print form, report or view — is the formula `SLICE()` on an `F` column (formulas.md).
261
262
  - As-of reads and `getTurnover` need a journal with both stamps.
@@ -265,6 +266,10 @@ execute:
265
266
  - **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).
266
267
  - **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).
267
268
 
269
+ ### Showing a balance on a record: a `Q` column
270
+
271
+ `getBalance` answers one tuple inside an action. To show a document its register's balance **on its own date**, as a list on the card and in print forms, declare a query column (`columnType: "Q"`, `params`: `register`, `asOf`, `dimensions`) instead of an action that computes it. Install refuses a register that is not `balance` or has no dated journal. It reads with the reader's rights and cannot read the totals cache. See column-types.md § Query columns.
272
+
268
273
  ## Totals cache
269
274
 
270
275
  `totals` caches the turnover inside each `day`/`month`/`quarter`/`year`, never a closing balance, so a backdated posting touches one row. Declare an `entity` whose PRIMARY KEY is exactly (dimensions, `periodField`), with `inField`/`outField` per resource (`debitField`/`creditField` and an `accountField` on accounting). Posting maintains it; install rebuilds it when its shape changes. Not available on `info`. Details: registers.md section 8.4.
@@ -288,6 +293,7 @@ For a history loaded from another system, an `accounting` register may declare `
288
293
  - **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.
289
294
  - **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.
290
295
  - **"May edit" is not "may post".** Without `postAction` / `unpostAction` a role with Insert/Update on the document can post it through the API (`data.update` with the state flag) though it cannot run the Post action. **Unreleased: not in platform 1.28.12**, so do not declare these keys against that platform. Declare both (declaring one leaves the other direction open), and grant `E` on the actions (`"action:post_entry": "E"`) only to the roles that may post. Each name must be declared for that entity in the `ui/actions.json` of the package that owns the column; `module pack` refuses any other, including an action a bridge adds to the host's entity. `E` guards only the direct API path: an action with `E` that calls `update()` on the state flag can still post, so guard that inside its `execute:` (check the role or state and `error()`).
296
+ - **A `Q` column over a register without a stamped journal.** The balance on a date is the state less the later movements, so the journal needs `journalDate` and `journalDirection`; install refuses the column otherwise.
291
297
  - **Missing or unlocked `dateField`.** Required once the register has `journalDate` or `period`, and it must be in `lockedFields`.
292
298
  - **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.
293
299
  - **State column narrower than the journal column.** It must hold the journal's value exactly, or unposting cannot take back what posting added.
@@ -391,7 +391,7 @@ Each entry becomes one attachment row, and the report gains a toolbar entry on t
391
391
  | Rule | Detail |
392
392
  |---|---|
393
393
  | `entityCd` | Qualified `module.entity` for anything outside this module, and that module must be a **declared dependency**. |
394
- | Source columns | The entity PK, a reference (`R`) column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code). `text`, `json`, `file`/`image` are rejected. |
394
+ | Source columns | The entity PK, a reference (`R`) column, or a bounded scalar (number, date/datetime, bool, dropdown/radio/flags code). `text`, `json`, `file`/`image` are rejected, and so is a set (`S`), formula (`F`) or query (`Q`) column: it holds no value of its own. |
395
395
  | Types | Source and target must be compatible; only `lookup`↔`number` and `date`↔`datetime` widen. A names-only match like `{ "customer_id": "created_date" }` is an error, not a runtime surprise. |
396
396
  | One per pair | One attachment per (entity, report). Two entries for the same entity overwrite each other. |
397
397
  | Unmapped params | Keep their normal behaviour — default value, or prompt the user. |