@dforge-core/dforge-mcp 0.2.47 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/dist/server.js +20 -4
  3. package/package.json +3 -2
  4. package/resources/schemas/entity.schema.json +96 -2
  5. package/resources/schemas/jobs.schema.json +1 -1
  6. package/resources/schemas/print-templates.schema.json +5 -0
  7. package/skills/dforge-1c-import/scripts/README.md +27 -0
  8. package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
  9. package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
  10. package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
  11. package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
  12. package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  13. package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
  14. package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
  15. package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
  16. package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
  17. package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
  18. package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
  19. package/skills/dforge-1c-import/scripts/lib/delta.py +40 -12
  20. package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
  21. package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
  22. package/skills/dforge-mcp-author/references/action-dsl.md +48 -45
  23. package/skills/dforge-mcp-author/references/column-types.md +38 -2
  24. package/skills/dforge-mcp-author/references/data-views.md +2 -0
  25. package/skills/dforge-mcp-author/references/formulas.md +21 -6
  26. package/skills/dforge-mcp-author/references/jobs.md +1 -1
  27. package/skills/dforge-mcp-author/references/print-templates.md +9 -5
  28. package/skills/dforge-mcp-author/references/registers.md +8 -0
  29. package/skills/dforge-mcp-author/references/reports.md +1 -1
  30. package/skills/dforge-mcp-author/references/security.md +2 -0
  31. package/skills/dforge-mcp-author/references/validation-checklist.md +6 -8
package/CHANGELOG.md CHANGED
@@ -4,7 +4,48 @@ 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.
15
+
16
+ **1C migration readiness and regressions.** `delta --apply` now reports per-module
17
+ `ready` / `partial` status and exits 2 if state, totals or imported markers remain
18
+ unsettled; retained retry markers are settled on a later run even without new rows.
19
+ Dry runs do not certify readiness. `pnpm test:1c` covers conversion precision,
20
+ reference/date semantics, failed settlement/retry and the per-entity reload restart
21
+ contract using synthetic fixtures and mocked database boundaries. The loader remains
22
+ a non-atomic migration-target reload; its restart procedure is documented.
23
+ A new CI job (`.github/workflows/onec-tests.yml`) runs it on changes to the 1C scripts or tests. A module's retry
24
+ markers (`df1c.unsettled`) are now cleared only for the entities it settled, not for the whole module.
25
+ Placeholder, balance-state and yearly totals writers roll back failed transactions before
26
+ restoring autocommit; settlement continues to the next module only after connection
27
+ recovery, and failed recovery stops the command. Strict transaction-state tests cover
28
+ original-error preservation, partial yearly commits, continuation and unrecoverable connections.
29
+ Jobs authoring/schema guidance now describes `idempotencyKey` as reserved and not enforced.
30
+
31
+ **Action scripts bypass entity rights.** `action-dsl.md` and `security.md` now state that `insert()` / `update()` /
32
+ `delete()` / `query()` in an action script check neither entity rights nor column security nor the folder's row filter
33
+ (core #1580): only `E` on the action is checked, so treat `E` as write access to everything the script touches.
34
+
35
+ **`canExecute` is enforced in full on the server.** `action.execute` now computes arithmetic, `IN`, `BETWEEN`, formula
36
+ functions, a bare boolean column, `[ref].[col]` navigation and extension columns in SQL, instead of letting them through, and
37
+ `TODAY()` names the requester's day. A condition it cannot compute refuses the action. Install rejects `$[setting]`,
38
+ `SUM` / `COUNT` / `AVG` and unknown function names in `canExecute`, so a module that used them stops installing: read
39
+ settings and count children in `execute:` instead (a count belongs in a `G` column). `references/action-dsl.md`
40
+ (`canExecute:`), `formulas.md` and `validation-checklist.md` are rewritten to match. The button still evaluates over the
41
+ grid row, which has no extension columns and throws on `[ref].[col]`, so keep both out of a gate that must also disable the
42
+ button. **Needs platform 1.28.12.**
43
+
44
+ **Registers: `postAction` / `unpostAction`.** `references/registers.md` documents the register-binding column params that make a direct
45
+ `data.insert` / `data.update` flipping the state flag need `E` on the post or unpost action, and the "May edit is not may
46
+ post" pitfall: declare both and grant `E` only to the roles that may post. Each direction is checked only when its own key
47
+ is set; actions and DSL `update()` are not re-checked. A binding with neither gets a pack / validate warning, silenced by
48
+ `"editorMayPost": true`. **Needs a platform release after 1.28.12 (not in it).**
8
49
 
9
50
  ## 0.2.46 — 2026-10-29
10
51
 
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.47",
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
  },
@@ -42,6 +42,7 @@
42
42
  "sync-schemas": "node scripts/vendor-schemas.cjs",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "test": "vitest run",
45
+ "test:1c": "python3 -m unittest discover -s test/onec -v",
45
46
  "install-skills": "node scripts/install-skills.mjs"
46
47
  }
47
48
  }
@@ -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
@@ -162,3 +162,30 @@ reproducible.
162
162
  This copy is kept in step with the port it was developed on (`tools/lib`, `tools/1c.py`
163
163
  there). Fixes made in a port go back to the library here. A project's own files never
164
164
  come here.
165
+
166
+ ### Regression checks and restart contract
167
+
168
+ From the MCP repository root: `pnpm test:1c` (Python 3, standard library only).
169
+ These tests use synthetic inputs and mocked database boundaries, never a tenant or
170
+ source infobase. They cover conversions, COPY encoding, settlement failure/retry and
171
+ the initial loader's committed-per-entity restart contract; they do not replace
172
+ Parquet/DuckDB/PostgreSQL integration or real source-to-target reconciliation.
173
+
174
+ `delta --apply` prints each module's `ready` or `partial` status. Exit 0 means all
175
+ selected modules settled; exit 2 means data may have committed but settlement is
176
+ incomplete (state/totals drift or markers not converged, including `--no-mark`
177
+ when markers are needed). Resolve the reported problem and rerun `--apply`;
178
+ persisted unsettled entities are checked even when the new delta has no changes.
179
+ Dry runs report `dry_run` and do not certify readiness. Settlement write failures
180
+ roll back the current transaction before restoring autocommit; ordinary failures
181
+ report `partial` and allow the next module to run on a clean connection. Failure to
182
+ rollback or restore the connection stops the command immediately. Previously
183
+ committed entities/years remain committed; a rollback does not undo the whole import.
184
+ Exit 0 does not replace the wider migration reconciliation.
185
+
186
+ `migrate run --truncate` is a destructive reload into an isolated migration target,
187
+ not an atomic refresh of a live application: clearing commits before loading and
188
+ each entity commits separately. If interrupted, keep users out, repair the cause,
189
+ rerun the same complete selection with `--truncate` from the same export, then
190
+ reconcile again. Do not use an append rerun to recover a partially loaded target.
191
+ For an active target, load/reconcile a separate database and switch only afterward.
@@ -56,6 +56,7 @@ taken for a deletion. While 1C is the system of record, nothing is entered in th
56
56
  from __future__ import print_function
57
57
 
58
58
  import argparse
59
+ import collections
59
60
  import datetime
60
61
  import re
61
62
  import struct
@@ -65,6 +66,7 @@ import time
65
66
  import migrate
66
67
  import paths
67
68
  import store
69
+ import transactions
68
70
 
69
71
  SCHEMA = "df1c"
70
72
  KEYS_IN_LIST = 500
@@ -515,6 +517,14 @@ def begin(cur):
515
517
 
516
518
  # --------------------------------------------------------------------------- after a module
517
519
 
520
+ SettleResult = collections.namedtuple("SettleResult", "status notes")
521
+
522
+
523
+ def settlement_exit_code(outcomes, apply):
524
+ """Dry runs only estimate; an applied but unfinished module must block cutover."""
525
+ return 2 if apply and any(o.status != "ready" for o in outcomes) else 0
526
+
527
+
518
528
  def settle(pg, module, results, apply, mark_documents):
519
529
  """What a module needs once its rows moved: placeholders, balances, markers."""
520
530
  import posted
@@ -526,11 +536,11 @@ def settle(pg, module, results, apply, mark_documents):
526
536
  notes = []
527
537
  settled = True
528
538
  if apply and touched:
529
- cur = pg.cursor()
530
- cur.execute("BEGIN")
531
- cur.execute("SET LOCAL session_replication_role = 'replica'")
532
- filled = migrate.fill_missing(pg, schemas, verbose=False)
533
- cur.execute("COMMIT")
539
+ with transactions.writing(pg):
540
+ cur = pg.cursor()
541
+ cur.execute("SET LOCAL session_replication_role = 'replica'")
542
+ filled = migrate.fill_missing(pg, schemas, verbose=False)
543
+ pg.commit()
534
544
  if filled:
535
545
  notes.append("%d placeholder(s)" % sum(f[4] for f in filled))
536
546
 
@@ -539,9 +549,8 @@ def settle(pg, module, results, apply, mark_documents):
539
549
  if reg.journal_entity not in touched:
540
550
  continue
541
551
  if apply:
542
- pg.autocommit = False
543
- rows, written, _ = seedstate.seed(pg, reg)
544
- pg.autocommit = True
552
+ with transactions.writing(pg):
553
+ rows, written, _ = seedstate.seed(pg, reg)
545
554
  off = seedstate.drift(pg.cursor(), reg)
546
555
  settled = settled and not off
547
556
  notes.append("reseeded %s.%s: %d tuple(s), check %s"
@@ -587,8 +596,9 @@ def settle(pg, module, results, apply, mark_documents):
587
596
  else:
588
597
  notes.append("would converge markers on %d document table(s)" % len(work))
589
598
  if apply and touched and settled:
590
- pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
591
- return notes
599
+ pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s AND entity = ANY(%%s)"
600
+ % SCHEMA, (module, sorted(touched)))
601
+ return SettleResult("dry_run" if not apply else ("ready" if settled else "partial"), notes)
592
602
 
593
603
 
594
604
  # --------------------------------------------------------------------------- command
@@ -733,6 +743,7 @@ def main():
733
743
  print("-" * len(header))
734
744
  totals = [0, 0, 0, 0, 0.0]
735
745
  all_notes = []
746
+ outcomes = []
736
747
  for module in modules:
737
748
  results = []
738
749
  for m, job in plan:
@@ -753,7 +764,20 @@ def main():
753
764
  res.owners if kind in ("lines", "movements") else "",
754
765
  res.changed, res.new, res.deleted, res.read, res.compare, res.write))
755
766
  sys.stdout.flush()
756
- for note in settle(pg, module, results, args.apply, jwt if args.apply else None):
767
+ try:
768
+ outcome = settle(pg, module, results, args.apply, jwt if args.apply else None)
769
+ except transactions.ConnectionRecoveryError:
770
+ raise # Never continue on a connection whose cleanup failed.
771
+ except Exception as failure:
772
+ if not args.apply:
773
+ raise
774
+ transactions.recover(pg)
775
+ outcome = SettleResult("partial", ["settlement FAILED: %s: %s"
776
+ % (type(failure).__name__, failure)])
777
+ outcomes.append(outcome)
778
+ all_notes.append("%s: status %s%s" % (module, outcome.status,
779
+ "" if args.apply else " (estimate only, not a readiness verdict)"))
780
+ for note in outcome.notes:
757
781
  all_notes.append("%s: %s" % (module, note))
758
782
  print("-" * len(header))
759
783
  print("%d entities changed %d new %d deleted %d (owners/recorders replaced %d)"
@@ -763,7 +787,11 @@ def main():
763
787
  print(" " + note)
764
788
  print("(rows of an entity with nothing changed and under 2s are not listed;"
765
789
  " lines/movements count rows replaced as new/deleted)")
766
- return 0
790
+ code = settlement_exit_code(outcomes, args.apply)
791
+ if code:
792
+ print("delta incomplete (%s): do not cut over; resolve the notes and rerun --apply."
793
+ % ", ".join(m for m, o in zip(modules, outcomes) if o.status != "ready"))
794
+ return code
767
795
 
768
796
 
769
797
  if __name__ == "__main__":
@@ -50,6 +50,7 @@ import sys
50
50
  import time
51
51
 
52
52
  import migrate
53
+ import transactions
53
54
 
54
55
  GRANULARITIES = ("day", "month", "quarter", "year")
55
56
 
@@ -334,8 +335,7 @@ def rebuild(pg, plan):
334
335
 
335
336
  insert = ("INSERT INTO %s (%s) VALUES (%s)"
336
337
  % (plan.totals, ", ".join(key + cols), ", ".join(["%s"] * (len(key) + len(cols)))))
337
- pg.autocommit = False
338
- try:
338
+ with transactions.writing(pg):
339
339
  for year in years:
340
340
  cur.execute("SET LOCAL session_replication_role = 'replica'")
341
341
  cur.execute("DELETE FROM %s WHERE %s >= %%s AND %s < %%s"
@@ -344,8 +344,6 @@ def rebuild(pg, plan):
344
344
  if by_year.get(year):
345
345
  cur.executemany(insert, by_year[year])
346
346
  pg.commit()
347
- finally:
348
- pg.autocommit = True
349
347
  print("%-28s %7d rows written over %d year(s) checks %.0fs, fold %.0fs, write %.0fs"
350
348
  % (name(plan), len(rows), len(years), scanned - started, read - scanned,
351
349
  time.time() - read))
@@ -0,0 +1,28 @@
1
+ """Transaction cleanup shared by the migration settlement writers."""
2
+ from contextlib import contextmanager
3
+
4
+
5
+ class ConnectionRecoveryError(RuntimeError):
6
+ """The connection cannot safely be reused; abort rather than try another module."""
7
+
8
+
9
+ def recover(pg):
10
+ """Rollback before changing mode: psycopg2 forbids it in an active transaction."""
11
+ try:
12
+ pg.rollback()
13
+ pg.autocommit = True
14
+ except Exception as error:
15
+ raise ConnectionRecoveryError("cannot restore migration connection: %s" % error) from error
16
+
17
+
18
+ @contextmanager
19
+ def writing(pg):
20
+ """Writers commit explicitly; on any failure, rollback before restoring autocommit."""
21
+ pg.autocommit = False
22
+ try:
23
+ yield
24
+ except BaseException:
25
+ recover(pg)
26
+ raise
27
+ else:
28
+ pg.autocommit = True
@@ -117,53 +117,54 @@ Two consequences worth designing around:
117
117
  - **`canExecute: false` is the idiom for an action only automation may fire.** The
118
118
  server refuses it through `action.execute`; triggers and scheduled jobs invoke
119
119
  the script engine directly and are unaffected.
120
- - **Both evaluators have gaps, and both fail open.** The server is a scalar walker
121
- — comparisons between fields and literals, combined with `AND` / `OR` / `NOT`,
122
- nothing else — and lets anything richer through. The client implements the full documented formula
123
- function set, so ordinary functions are fine there; it throws (and its `catch`
124
- **enables** the button) on **ref navigation** (`[ref].target`), on the set
125
- aggregates **`SUM` / `COUNT` / `AVG`** (deliberately not formula functions —
126
- counting is a Generated `G` column's job), and on **any name it does not know, a
127
- typo included** — a `canExecute` is never validated against the function set at
128
- install, so `TOADY()` ships and enables the button for every record.
129
- - **`$[setting]` references are a separate case.** They do not throw: the client's
130
- resolver is stubbed to `null`, so the predicate resolves however that operator
131
- treats null (an ordered comparison against null is `false`, so it tends to
132
- *disable* the button), while the server has no settings resolver and fails open.
133
- Keep settings out of `canExecute` and read them in `execute:` instead.
134
-
135
- | tier | construct | client | server |
136
- |---|---|---|---|
137
- | 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
138
- | 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
139
- | 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
140
- | — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
141
-
142
- **A bare boolean column is not tier 1.** The walker evaluates conditions, not
143
- values — a field is only resolvable as an operand of a comparison — so
144
- `canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
145
- `[is_active] = true` and `NOT ([is_blocked] = true)`. (`canExecute: false` is fine:
146
- a bare literal is a complete condition.)
147
-
148
- Mixing tier 1 and tier 2 is the normal, healthy shape: the client evaluates the
149
- whole thing so the button state is right, and the server short-circuits `AND` /
150
- `OR` so the tier-1 half is still enforced.
151
-
152
- **Tier 3 is what breaks.** The server short-circuits, the client does not — so
153
- `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
154
- evaluates `COUNT` in the browser, throws, and the button is **enabled**, then the
155
- server refuses the call: an error where a greyed-out button was wanted. And a
156
- `canExecute` is not validated against the function set at install, so that spelling
157
- ships silently. Count with a `G` column and compare the scalar
158
- (`[detail_count] > 0`); denormalise instead of navigating; put anything genuinely
159
- unexpressible in `execute:` behind `error()`.
120
+ - **The server evaluates the whole formula, and refuses what it cannot read.**
121
+ (Needs platform 1.28.12; older platforms let these through, so the gate is not enforced there.)
122
+ Comparisons between fields and literals (with `AND` / `OR` / `NOT`, a bare
123
+ `true` / `false`) are checked in-process; anything richer — arithmetic, `IN`,
124
+ `BETWEEN`, formula functions, a bare boolean field, `[ref].[col]`
125
+ navigation — is computed in SQL over the record, by the same translator as formula
126
+ columns (chained hops are followed up to a depth cap; a null ref gives `NULL`, which
127
+ counts as not true). A condition the server cannot evaluate (`$[setting]` references, set
128
+ aggregates `SUM` / `COUNT` / `AVG`, an unknown function name, a typo included)
129
+ **refuses the action** with `ACTION_EXECUTION_FAIL` ("cannot be evaluated by the
130
+ server") instead of allowing it. A `NULL` result counts as not true.
131
+ Comparisons themselves are null-safe and two-valued (see `formulas.md`), so an empty
132
+ field does not make `NOT ([x] = 'A')` unknown.
133
+ - **The client has gaps of its own, and its failures enable the button.** It
134
+ throws (and its `catch` **enables** the button) on **ref navigation**
135
+ (`[ref].target`), on **`SUM` / `COUNT` / `AVG`** (deliberately not formula
136
+ functions — counting is a Generated `G` column's job) and on **any name it does
137
+ not know, a typo included** (install refuses the last two, see below).
138
+ - **`$[setting]` references** resolve to `null` in the client (an ordered comparison
139
+ against null is `false`, so it tends to *disable* the button) and are refused by
140
+ the server and at install (platform 1.28.12 and later). Keep settings out of `canExecute` and read them in `execute:` instead.
141
+ - **`TODAY()`** names the requester's calendar day on the server too (their time
142
+ zone: the request's, else the user profile's, else the workspace's, else UTC; a profile zone of
143
+ `UTC` counts as unset). The button is evaluated in the browser and is meant to name the same
144
+ day; `execute:` stays the authority.
145
+ - **Install refuses** a `canExecute` that uses `$[setting]`, `SUM` / `COUNT` / `AVG`
146
+ or an unknown function name (a typo included), since the server could never run
147
+ the action. Read settings and count children in `execute:` instead.
148
+
149
+ | construct | client | server |
150
+ |---|---|---|
151
+ | comparisons between fields and literals, `AND` / `OR` / `NOT`, bare `true` / `false` | evaluates | evaluates |
152
+ | arithmetic, `IN`, `BETWEEN`, formula functions, bare boolean field | evaluates | evaluates (SQL) |
153
+ | `[ref].[col]` navigation | **throws, button enabled** | evaluates (SQL) |
154
+ | `SUM` / `COUNT` / `AVG`, unknown function (a typo included) | **throws, button enabled** | **refused at install**; if one is already installed, `action.execute` refuses it |
155
+ | `$[setting]` references | evaluates as null | **refused at install**; if already installed, `action.execute` refuses it |
156
+
157
+ Both evaluators agreeing is the healthy shape; the one to design around is a
158
+ construct the client throws on, which leaves a button that is enabled and then
159
+ refused. Count with a `G` column and compare the scalar (`[detail_count] > 0`);
160
+ denormalise instead of navigating; put anything genuinely unexpressible in
161
+ `execute:` behind `error()`.
160
162
 
161
163
  > Never make `canExecute` the *only* guard for something that must hold. Between
162
- > the fail-open rules on both sides, the enqueue-only check on background runs, and
163
- > the fact that the client evaluates over the grid row (which does not carry
164
- > extension columns) while the server evaluates over a full record load, the
165
- > authoritative check belongs in `execute:` — validate and `error()` before the
166
- > first write.
164
+ > the enqueue-only check on background runs and the fact that the client evaluates
165
+ > over the grid row (which does not carry extension columns) while the server
166
+ > evaluates over a full record load, the authoritative check belongs in
167
+ > `execute:` — validate and `error()` before the first write.
167
168
 
168
169
  ```
169
170
  canExecute:
@@ -226,6 +227,8 @@ var note = params[note] // no quotes around param name in this DSL
226
227
 
227
228
  ### Data operations
228
229
 
230
+ **No entity rights are checked inside a script.** `insert()`, `update()`, `delete()` and `query()` do not look at the signed-in user's `S`/`I`/`U`/`D` on the entity, nor at column security or the folder's row filter. Only `E` on the action is checked, when it is run. A user with `S` only on an entity can still change it through an action they may execute (core #1580). Treat `E` on an action as write access to everything its script touches; to keep a log free of changes, grant `E` on no action that writes to it.
231
+
229
232
  | Function | Returns | Description |
230
233
  |---|---|---|
231
234
  | `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
@@ -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.
@@ -11,11 +11,12 @@ Formulas are used in:
11
11
  `F` column otherwise: it must parse, call only implemented functions, and not
12
12
  aggregate over a 1:N set. See `security.md` → Column-level security.
13
13
  - **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
14
- and **enforced** by `action.execute`, but only over a narrow subset: comparisons
15
- between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
16
- arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
17
- open server-side, and some of them throw client-side. See the `canExecute:`
18
- section in [action-dsl.md](action-dsl.md).
14
+ and **enforced** by `action.execute`: the server computes the whole formula
15
+ (functions, arithmetic, navigation included) and **refuses** the call when
16
+ it cannot read it (`$[setting]` reads, `SUM` / `COUNT` / `AVG`, unknown
17
+ functions). The client throws on navigation, set aggregates and unknown names,
18
+ which leaves the button enabled. See the `canExecute:` section in
19
+ [action-dsl.md](action-dsl.md).
19
20
  - **Default values** — a setting's `formula` (e.g. `"formula": "TODAY()"`) or a formula (`F`) column. Note: entity *data* columns have **no** `default`/`defaultValue` key — model a default with an `F` column or set it in action/trigger logic.
20
21
  - **Filter expressions** (partially)
21
22
  - **Validation expressions** (partially)
@@ -166,6 +167,19 @@ The whole date family evaluates identically **client-side and SQL-time** (report
166
167
  `dforge_module_validate` and `dforge_module_pack` (the CLI's static checks) catch only a
167
168
  malformed call, such as a name that is not a text literal or a dimension without its value;
168
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.
169
183
  - Refused at install in a `G` column: it is computed when a row is written and would not
170
184
  follow a record added to the register later. Use an `F` column.
171
185
  - Newest record only: there is no `first` mode as `getSlice` has. Each row is one index
@@ -420,7 +434,8 @@ An `F` column is virtual: not stored, not editable. To fill a **stored, editable
420
434
  - Using JavaScript syntax like `row.field` or `this.field` — **wrong**. Only `[field]`.
421
435
  - Forgetting `baseDatatypeCd` on formula columns — **required**. Without it, filters and SQL don't work.
422
436
  - Using SQL syntax like `SELECT`, `JOIN`, `WHERE` — **wrong**. Formulas are expressions, not queries.
423
- - 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], '') != ''`.
424
439
 
425
440
  ## Reference
426
441
 
@@ -33,7 +33,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
33
33
  | `timeout` | Yes | | Hard timeout in seconds. Range `(0, 3600]`. **No implicit default.** `timeout > 300` requires `class: "long_running"`. |
34
34
  | `class` | No | `"standard"` | `"standard"` (≤ 300 s) or `"long_running"` (≤ 3600 s). Explicit opt-in for heavy jobs. |
35
35
  | `concurrency` | No | `1` | Max parallel runs `[1, 5]`. `1` = single-instance. |
36
- | `idempotencyKey` | No | | Template like `"{job_cd}:{run.month}"`. Successful run with same key blocks re-dispatch. |
36
+ | `idempotencyKey` | No | | **Not enforced yet.** Accepted and stored; the Scheduler does not read it. Scheduled slots deduplicate `(job, scheduled_at)`, not business keys across slots. Guard business idempotency inside the action; manual triggers skip the slot check. |
37
37
  | `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
38
38
  | `params` | No | `{}` | Static parameters passed to the action as `__params`. Manifest-fixed — an admin cannot change them. For tenant-configurable values use module settings, resolved from the job's folder (below). |
39
39
  | `enabled` | No | `true` | `false` fully disables — no scheduler, no manual trigger. |
@@ -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
@@ -188,6 +188,7 @@ A register column is a `bool` state column. `A` writes `balance`, `turnover` and
188
188
  | `lineDateField` | On a line posting, a line column dating that line's movement instead of `dateField`; an empty one falls back to `dateField`. With `periodEntity` each line's period must be open, judged by the movement's stamped date (line's or document's, after `dateTrunc`); lines skipped by `skipPostingWhenEmpty` don't count. On a `recordChangesOnly` info register, one line per key (several dates on a key are refused). A `date` or zoneless `timestamp`, in `lockedLineFields` (and `groupLinesBy` when grouped), not with `periodDimField`. |
189
189
  | `skipPostingWhenEmpty` | Dimensions whose emptiness means "this row posts nothing". The exception, see below. |
190
190
  | `lockedFields` / `lockedLineFields` | Columns frozen while posted. `["*"]` freezes the whole row. |
191
+ | `postAction` / `unpostAction` | Params of the binding column (an `A` or `L` column), codes of this entity's actions that post and unpost the document. A direct `data.insert` / `data.update` that turns the state flag on or off needs `E` on the one for its direction, as the button does; actions and DSL `update()` are not re-checked. Each direction is gated on its own: with only one declared, the other direction needs just Insert/Update. A binding with neither gets a pack / validate warning, silenced by `"editorMayPost": true` (not allowed together with an action name). A name no action declared for the entity in `ui/actions.json` carries is refused by `module pack` / validate and by the write. **Unreleased: not in platform 1.28.12.** |
191
192
  | `movementMapping` | Journal attribute column to document or line column. |
192
193
 
193
194
  ### `map` value forms
@@ -255,6 +256,7 @@ execute:
255
256
  var price = getSlice("wms-registry.outlet_price", { warehouse_id: [warehouse_id], product_id: [product_id] }, [order_date])
256
257
  ```
257
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.
258
260
  - A dimension left out is summed over by `getBalance` and `getTurnover`. `getSlice` refuses an omitted dimension.
259
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).
260
262
  - As-of reads and `getTurnover` need a journal with both stamps.
@@ -264,6 +266,10 @@ execute:
264
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).
265
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).
266
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
+
267
273
  ## Totals cache
268
274
 
269
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.
@@ -286,6 +292,8 @@ For a history loaded from another system, an `accounting` register may declare `
286
292
  - **One constant for two resources.** Two resources mapped to the same constant read as two resources storing into one column and are refused.
287
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.
288
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.
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.
289
297
  - **Missing or unlocked `dateField`.** Required once the register has `journalDate` or `period`, and it must be in `lockedFields`.
290
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.
291
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. |
@@ -81,6 +81,8 @@ Example:
81
81
  |---|---|
82
82
  | `E` | Execute (run the action/report/stored procedure, access the folder) |
83
83
 
84
+ **`E` on an action is not limited by the user's entity rights.** The gate is on running the action, not on the statements inside it: `insert()` / `update()` / `delete()` / `query()` in its script work on an entity where the user has only `S`. Entity rights apply to the grid, the card and the API, not to action scripts (core #1580).
85
+
84
86
  ### Common combinations
85
87
 
86
88
  | Rights | Meaning |
@@ -122,14 +122,12 @@ For each action:
122
122
  - [ ] DSL file exists at `logic/actions/<script>.dsl`, where `<script>` is the bare filename in the action's `script` field (no path, no `.dsl` extension)
123
123
  - [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
124
124
  - [ ] `canExecute:` is a valid formula expression
125
- - [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
126
- guard is expressed in the server-enforced subset — **comparisons** between
127
- fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
128
- (`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
129
- `$[setting]` reads are fail-open server-side and are advisory only
130
- - [ ] No **bare boolean field** as a whole condition — `[is_active]` and
131
- `NOT [is_blocked]` are not enforced (a field is only an operand). Write
132
- `[is_active] = true` / `NOT ([is_blocked] = true)`
125
+ - [ ] `canExecute:` uses only constructs the server can evaluate — fields,
126
+ literals, comparisons, arithmetic, `IN`, formula functions, a bare boolean
127
+ field. `$[setting]` reads, `SUM` / `COUNT` / `AVG` and unknown function
128
+ names are **refused at install**. `[ref].[col]` works on the
129
+ server but the client throws and **enables** the button: denormalise it
130
+ into a column, and count with a `G` column
133
131
  - [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
134
132
  `canExecute` alone is never the authoritative guard
135
133
  - [ ] `execute:` uses only documented built-in functions