@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.
- package/CHANGELOG.md +42 -1
- package/dist/server.js +20 -4
- package/package.json +3 -2
- package/resources/schemas/entity.schema.json +96 -2
- package/resources/schemas/jobs.schema.json +1 -1
- package/resources/schemas/print-templates.schema.json +5 -0
- package/skills/dforge-1c-import/scripts/README.md +27 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +40 -12
- package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
- package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +48 -45
- package/skills/dforge-mcp-author/references/column-types.md +38 -2
- package/skills/dforge-mcp-author/references/data-views.md +2 -0
- package/skills/dforge-mcp-author/references/formulas.md +21 -6
- package/skills/dforge-mcp-author/references/jobs.md +1 -1
- package/skills/dforge-mcp-author/references/print-templates.md +9 -5
- package/skills/dforge-mcp-author/references/registers.md +8 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +2 -0
- 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
|
-
##
|
|
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.
|
|
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),
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
Binary file
|
|
@@ -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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
543
|
-
|
|
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
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`[
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
163
|
-
>
|
|
164
|
-
>
|
|
165
|
-
>
|
|
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).
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
section in
|
|
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 | |
|
|
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) |
|
|
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
|
|
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
|
|
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 %
|
|
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
|
-
-
|
|
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
|
-
- [ ]
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|