@dforge-core/dforge-mcp 0.2.46-next → 0.2.48

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 (52) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/dist/server.js +71 -3
  3. package/package.json +47 -48
  4. package/resources/docs/dsl-reference.md +1 -1
  5. package/resources/schemas/data-views.schema.json +50 -0
  6. package/skills/dforge-1c-import/SKILL.md +3 -2
  7. package/skills/dforge-1c-import/references/environment.md +2 -1
  8. package/skills/dforge-1c-import/references/loading.md +5 -2
  9. package/skills/dforge-1c-import/references/measurements.md +1 -1
  10. package/skills/dforge-1c-import/scripts/README.md +88 -7
  11. package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
  12. package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
  13. package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
  14. package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
  15. package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
  17. package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
  18. package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
  19. package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
  20. package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
  21. package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
  22. package/skills/dforge-1c-import/scripts/lib/attrs.py +2 -2
  23. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  24. package/skills/dforge-1c-import/scripts/lib/delta.py +109 -52
  25. package/skills/dforge-1c-import/scripts/lib/docusage.py +5 -4
  26. package/skills/dforge-1c-import/scripts/lib/dump1c.py +249 -92
  27. package/skills/dforge-1c-import/scripts/lib/external.py +4 -3
  28. package/skills/dforge-1c-import/scripts/lib/meta.py +12 -2
  29. package/skills/dforge-1c-import/scripts/lib/migrate.py +93 -74
  30. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  31. package/skills/dforge-1c-import/scripts/lib/paths.py +21 -0
  32. package/skills/dforge-1c-import/scripts/lib/peek.py +3 -5
  33. package/skills/dforge-1c-import/scripts/lib/recorded.py +11 -11
  34. package/skills/dforge-1c-import/scripts/lib/recorders.py +9 -12
  35. package/skills/dforge-1c-import/scripts/lib/seedstate.py +21 -16
  36. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  37. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  38. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  39. package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
  40. package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
  41. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  42. package/skills/dforge-1c-import/scripts/project-template/sources.py +10 -0
  43. package/skills/dforge-mcp-author/references/action-dsl.md +49 -46
  44. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  45. package/skills/dforge-mcp-author/references/formulas.md +38 -6
  46. package/skills/dforge-mcp-author/references/jobs.md +1 -1
  47. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  48. package/skills/dforge-mcp-author/references/queries.md +17 -2
  49. package/skills/dforge-mcp-author/references/registers.md +3 -0
  50. package/skills/dforge-mcp-author/references/reports.md +1 -1
  51. package/skills/dforge-mcp-author/references/security.md +2 -0
  52. package/skills/dforge-mcp-author/references/validation-checklist.md +7 -8
@@ -0,0 +1,126 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ The infobase as everything past the export sees it: `out/data/<table>.parquet`, one view
4
+ per table, queried with DuckDB. Nothing here knows whether the export came from MS SQL or
5
+ from a file infobase - `dump1c` made them the same - and nothing past here talks to 1C.
6
+
7
+ cur = store.connect().cursor()
8
+ cur.execute('SELECT "_IDRRef", "_EnumOrder" FROM "_Enum12"')
9
+
10
+ The SQL is DuckDB's, so a project's `sources.QUERIES` are too: names in double quotes
11
+ (DuckDB matches them without regard to case), `LIMIT` for `TOP`, `from_hex('..')` - see
12
+ `literal` - for a binary constant. The values are as 1C stores them: a server infobase's
13
+ dates still carry its year offset (`year_offset()`), a boolean is binary(1).
14
+
15
+ The cursor takes parameters the way pyodbc did, `execute(sql, a, b)` or `execute(sql, [a, b])`,
16
+ so the readers of the infobase kept their shape when they stopped reading MS SQL.
17
+ """
18
+ import codecs
19
+ import json
20
+ import os
21
+
22
+ import paths
23
+
24
+ DATA = os.path.join(paths.OUT, "data")
25
+ MANIFEST = os.path.join(paths.OUT, "export.json")
26
+
27
+ # A boolean as 1C stores it, binary(1): `_Posted`, `_Marked`, `_Folder`.
28
+ TRUE = b"\x01"
29
+
30
+ _MANIFEST = []
31
+
32
+
33
+ def manifest():
34
+ if not _MANIFEST:
35
+ if not os.path.exists(MANIFEST):
36
+ raise SystemExit("no export in %s - run `1c.py dump1c all` first" % paths.OUT)
37
+ with codecs.open(MANIFEST, "r", "utf-8") as f:
38
+ _MANIFEST.append(json.load(f))
39
+ return _MANIFEST[0]
40
+
41
+
42
+ def year_offset():
43
+ """What 1C adds to a year when it stores a date: 2000 on MS SQL as a rule, 0 in a file."""
44
+ return int(manifest().get("year_offset", 0))
45
+
46
+
47
+ def source_kind():
48
+ return manifest()["source"]["kind"]
49
+
50
+
51
+ def horizon():
52
+ """The oldest horizon any exported table was read after, or None for a file export.
53
+
54
+ Every change 1C committed below it is in the export. One mark for the whole export
55
+ rather than one per table is the cautious reading: a declared query may join tables
56
+ read at different moments, and a mark too low only re-reads, never misses.
57
+ """
58
+ marks = [t.get("horizon") for t in manifest()["tables"].values()]
59
+ if not marks or any(m is None for m in marks):
60
+ return None
61
+ return bytes.fromhex(min(marks))
62
+
63
+
64
+ def literal(raw):
65
+ """A binary constant in SQL - what `0x...` was in T-SQL."""
66
+ return "from_hex('%s')" % bytes(raw).hex()
67
+
68
+
69
+ def quote(name):
70
+ return '"%s"' % name.replace('"', '""')
71
+
72
+
73
+ class Cursor(object):
74
+ def __init__(self, con):
75
+ self.con = con
76
+
77
+ def execute(self, sql, *params):
78
+ if len(params) == 1 and isinstance(params[0], (list, tuple)):
79
+ params = params[0]
80
+ self.con.execute(sql, list(params))
81
+ return self
82
+
83
+ @property
84
+ def description(self):
85
+ return self.con.description
86
+
87
+ def fetchone(self):
88
+ return self.con.fetchone()
89
+
90
+ def fetchall(self):
91
+ return self.con.fetchall()
92
+
93
+ def fetchmany(self, size):
94
+ return self.con.fetchmany(size)
95
+
96
+
97
+ class Database(object):
98
+ def __init__(self):
99
+ import duckdb
100
+ self.con = duckdb.connect()
101
+ # Spill under out/, and only so far. A query that loses its window - a LATERAL join
102
+ # DuckDB filters above rather than at the scan - reads all 99.8 M analytic rows and
103
+ # spilled 17 GB before it died, nearly the whole of the disk it shared with the system.
104
+ spill = os.path.join(paths.OUT, ".duckdb")
105
+ self.con.execute("SET temp_directory = '%s'" % spill.replace("\\", "/").replace("'", "''"))
106
+ self.con.execute("SET max_temp_directory_size = '%s'" % os.environ.get("DF1C_DUCKDB_TEMP", "8GB"))
107
+ tables = manifest()["tables"]
108
+ for name in sorted(tables):
109
+ path = os.path.join(DATA, name + ".parquet")
110
+ if os.path.exists(path):
111
+ self.con.execute("CREATE VIEW %s AS SELECT * FROM read_parquet('%s')"
112
+ % (quote(name), path.replace("\\", "/").replace("'", "''")))
113
+ self.tables = tables
114
+
115
+ def cursor(self):
116
+ return Cursor(self.con.cursor())
117
+
118
+
119
+ _DB = []
120
+
121
+
122
+ def connect():
123
+ """One DuckDB per process; each cursor is its own connection to it."""
124
+ if not _DB:
125
+ _DB.append(Database())
126
+ return _DB[0]
@@ -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
@@ -0,0 +1,24 @@
1
+ # Copy to <port root>/.env and fill in. `.env` never goes into a commit.
2
+ #
3
+ # None of these has a default, deliberately: a default naming one infobase would make the
4
+ # toolkit quietly read or write that infobase whenever a variable was forgotten.
5
+
6
+ # DF1C_ROOT and DF1C_PROJECT are exported in the shell, not set here: this file is found
7
+ # through DF1C_ROOT (<root>/.env), so it cannot say where the root is.
8
+
9
+ # The infobase, read-only: a server infobase on MS SQL (with DF1C_USER empty, Windows
10
+ # authentication is used) ...
11
+ DF1C_SERVER=
12
+ DF1C_DB=
13
+ DF1C_USER=
14
+ DF1C_PASS=
15
+ # ... or a file infobase: the 1Cv8.1CD, or the folder holding it. Set this or the four above.
16
+ # Point it at a copy, or close the infobase first.
17
+ DF1C_FILE=
18
+
19
+ # The dForge tenant and its Postgres, for loading and checks
20
+ DF_TENANT=
21
+ DF_PG_HOST=localhost
22
+ DF_PG_PORT=
23
+ DF_PG_USER=
24
+ DF_PG_PASS=
@@ -6,6 +6,16 @@ records each column's 1C source as it emits the column. These four names exist b
6
6
  `binding.py` requires them; they stay empty for as long as no module here is written by
7
7
  hand. If one ever is, this is where its columns say where they came from - see
8
8
  `tools/project/sources.py` for the shape.
9
+
10
+ A query in QUERIES runs on the export (`lib/store.py`), so it is DuckDB SQL, not T-SQL:
11
+ `"_Fld123"` rather than `[_Fld123]`, `LIMIT` rather than `TOP`, `from_hex('01')` rather
12
+ than `0x01`.
13
+
14
+ Put a `/*window:<alias>*/` marker in the WHERE of the scan it narrows, and do not turn
15
+ T-SQL's CROSS APPLY into a LATERAL join: DuckDB applies a filter written above a LATERAL
16
+ after the join, so a query narrowed to one recorder still reads every analytic row - on
17
+ 99.8 M it ran out of memory and spilled 17 GB. A nested SELECT over the table, with the
18
+ marker in its own WHERE, ran in under a second. Spill is capped at DF1C_DUCKDB_TEMP (8GB).
9
19
  """
10
20
 
11
21
  ENUMS = {}
@@ -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`. |
@@ -359,7 +362,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
359
362
 
360
363
  | Function | Returns | Description |
361
364
  |---|---|---|
362
- | `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }` | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
365
+ | `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }`, plus `warnings` when a value printed empty because the user may not read it | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
363
366
  | `downloadText(content, fileName, encoding?, mimeType?)` | URL string | Hand the user a text file the script built. `encoding` is `utf-8` by default; `windows-1251`, `cp866`, `koi8-u` and other code pages work, and a character the encoding cannot represent is an error. Line endings are written as given (join with `'\r\n'` for CR LF), no BOM. Returns the signed download URL (valid an hour). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives it, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run. |
364
367
  | `attachText(content, fileName, encoding?, mimeType?)` | descriptor | Build a text file for `sendEmail()`'s fourth argument, keeping its `mimeType` (e.g. an `.ics` as `text/calendar`). Same encodings as `downloadText()`; max 10 MB per file, 10 files per run shared with the download functions. Works in a scheduled job. |
365
368
  | `attachBase64(base64, fileName, mimeType?)` | descriptor | `attachText()` for bytes. |
@@ -118,8 +118,8 @@ See [flags.md](flags.md) for the flag string, [column-types.md](column-types.md)
118
118
 
119
119
  | `viewType` | Description | `viewConfig` | When to use |
120
120
  |---|---|---|---|
121
- | `grid` | Spreadsheet-style table with sortable columns | — | Default for most entities; bulk data |
122
- | `list` | Compact list view | — | Mobile-friendly, card-sized items |
121
+ | `grid` | Spreadsheet-style table with sortable columns | `groupByField`, `groupByOptions`, `collapsedGroups` (all optional) | Default for most entities; bulk data |
122
+ | `list` | Compact list view | `groupByField`, `groupByOptions`, `collapsedGroups`, `inlineAdd`, `levels` (all optional) | Mobile-friendly, card-sized items |
123
123
  | `kanban` | Columns grouped by a status field | `groupByField` | Workflow entities with stages (deals, tickets) |
124
124
  | `gallery` | Image-forward card grid | — | Image-heavy entities (products, real estate) |
125
125
  | `tree-grid` | Hierarchical grid with expand/collapse | — | Tree structures (folders, categories, org charts) |
@@ -151,6 +151,42 @@ Some view types need additional configuration via the `viewConfig` property:
151
151
 
152
152
  `groupByField` — the dropdown/status column to group cards by. Each distinct value becomes a kanban column.
153
153
 
154
+ ### Grid and list grouping
155
+
156
+ A `grid` or `list` view can ship a default grouping. Rows split under a header per value (colour or icon, label, count),
157
+ and a header click folds its group away. Every key is optional; without them the view is ungrouped.
158
+
159
+ ```json
160
+ {
161
+ "tickets_grid": {
162
+ "viewType": "grid",
163
+ "label": "Tickets",
164
+ "dataSources": [{
165
+ "entityCode": "ticket",
166
+ "columns": ["subject", "status", "priority", "assignee_id"]
167
+ }],
168
+ "viewConfig": {
169
+ "groupByField": "status",
170
+ "groupByOptions": ["status", "priority", "assignee_id"],
171
+ "collapsedGroups": ["closed"]
172
+ }
173
+ }
174
+ }
175
+ ```
176
+
177
+ - `groupByField`: the column the rows are grouped by. It must be a dropdown, flags, reference (`columnType: "R"`) or
178
+ `user` column.
179
+ - `groupByOptions`: the columns the Group menu offers. Each follows the same rule, and the list must include
180
+ `groupByField`. Leave it out to offer every groupable column.
181
+ - `collapsedGroups`: values of `groupByField` whose groups start folded. It needs a valid `groupByField`, and for a
182
+ dropdown each entry must be one of its option values.
183
+ - `inlineAdd` (`list` only): an add row in each group, or at the top when ungrouped.
184
+
185
+ The install refuses a view that breaks these rules, and `dforge_module_validate` reports it first. The default is a
186
+ single level. A user can stack more levels (priority, then status) from the gear menu; that choice lives in their
187
+ session, not in the module. A grouped grid loads up to 2,000 rows instead of paging and warns past that. Needs platform
188
+ 1.28.11.
189
+
154
190
  ### Calendar
155
191
 
156
192
  ```json
@@ -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)
@@ -142,11 +143,42 @@ The whole date family evaluates identically **client-side and SQL-time** (report
142
143
  `'bool'`/`'boolean'`, `'date'`, `'datetime'`/`'timestamp'`/`'timestamptz'`
143
144
  - `CURRENT_USER_ID()` — the id of the requesting user
144
145
 
146
+ ### Register functions
147
+
148
+ - `SLICE('module.register', 'resource', date, 'dimension', value, …)` — an information
149
+ register's value in effect on `date` (1C `СрезПоследних`): the resource of the newest record
150
+ at or before the date whose dimensions equal the values, null when none is. The register,
151
+ resource and dimension names are text literals; the date and the values are expressions over
152
+ the row, so a document line reads its price on its document's date:
153
+ `SLICE('pricing.item_price', 'price', [doc].[doc_date], 'item_id', [item_id], 'price_type_id', [doc].[price_type_id])`.
154
+ Every dimension must be given; a register without `period` takes `null` as the date.
155
+ - **Computed by the database only** — in a print form, a report, and `data.get` naming the
156
+ column. The browser does not recompute it on edit; it keeps the value the server loaded.
157
+ - Read with the user's rights on the register's records: Select in the folder, the folder's
158
+ row filter, the resource and every dimension column visible in the folder's view. Otherwise
159
+ the cell is empty and the response carries a warning. That includes a row filter over the
160
+ records that cannot be applied (a dotted path or an extension column): the `SLICE()` comes
161
+ back empty with a warning instead of failing the whole query.
162
+ - Install refuses a register that is not an `info` register of this module or of a
163
+ dependency (a register of another kind "has no slice"), an unknown resource or dimension, a
164
+ missing dimension, and a literal `null` as a dimension value or as the date of a register
165
+ that declares a `period` (it matches no record). A `null` that only appears at runtime, such
166
+ as a line with an empty price type, still gives an empty cell, with no warning.
167
+ `dforge_module_validate` and `dforge_module_pack` (the CLI's static checks) catch only a
168
+ malformed call, such as a name that is not a text literal or a dimension without its value;
169
+ whether the register, resource and dimensions exist is known at install.
170
+ - Refused at install in a `G` column: it is computed when a row is written and would not
171
+ follow a record added to the register later. Use an `F` column.
172
+ - Newest record only: there is no `first` mode as `getSlice` has. Each row is one index
173
+ descent of the register's `(dimensions, period)` index, which the platform creates.
174
+ - The date is compared as `period <= date`: against a `timestamp` period, a date-only
175
+ document date is midnight, so a record later that day is not in the slice.
176
+
145
177
  ### Client-side vs SQL-time evaluation
146
178
 
147
179
  An `F` column is evaluated **client-side** in grids and cards, but translated to **SQL** when a
148
180
  report selects it, or when it is used in a filter or sort. **Every function listed here is
149
- implemented in both**, so a column reads the same in a grid and in a report; a test executes
181
+ implemented in both** — except `SLICE()`, which only the database computes — so a column reads the same in a grid and in a report; a test executes
150
182
  each one against PostgreSQL and diffs the result against the client runtime.
151
183
 
152
184
  Four deliberate points of agreement, because the engines disagree by nature:
@@ -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. |
@@ -73,7 +73,9 @@ A printed set, nested or not, needs **Select** on its entity in the folder, as `
73
73
 
74
74
  A currency column prints its glyph for the common codes (`EUR` → `€`, `USD` → `$`, `GBP` → `£`, `UAH` → `₴`, `PLN` → `zł`); other codes print as the code. `format_currency` follows the same mapping.
75
75
 
76
- Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references or calls a SQL function prints what the grid shows, and `TODAY()` / `NOW()` work. A formula the SQL translator cannot express prints empty and the response carries a warning naming the column.
76
+ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references prints what the grid shows, and `TODAY()` / `NOW()` work. A formula uses the built-in formula functions only: it cannot call a module's SQL function. A formula the SQL translator cannot express, or one reading what the user may not read, prints empty, and `print.render` / `print.renderPdf` return it in `warnings` (a line column's prefixed with its set's name); the card's PDF button shows them.
77
+
78
+ A value that changes over time — a price, an employee's position, an organization's code — lives in an information register: print it as it stood on the document's date with a `SLICE()` formula column on the line or the document (see formulas.md). Each line reads its own value in the query that loads the set.
77
79
 
78
80
  **Sets: loading and order.** A set is loaded when the template's Liquid code (`{% … %}` or `{{ … }}`) mentions its name anywhere: a `for`, a filter, a condition, `.size`, `.first`. Plain text, `{% comment %}` / `{% raw %}` blocks, string literals and the member of a path (`rec.lines`) do not count, and a snippet's code is not scanned (mention the set in the host template). Rows come in primary-key order; sort in the template:
79
81
 
@@ -85,6 +87,21 @@ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does
85
87
 
86
88
  `sort` takes a column code and sorts the value as the template sees it (a dropdown or reference sorts by its label). Text order follows the engine, not the database collation. `{# … #}` after an expression in examples is a note for the reader, not syntax: write a real comment as `{% comment %} … {% endcomment %}`.
87
89
 
90
+ ## Computed values and grouped rows
91
+
92
+ | The form needs | Do |
93
+ | --- | --- |
94
+ | 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
+ | A value per line (places, weight through the product's units) | An `F` column on the line entity, printed as `line.<col>` |
96
+ | 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 |
98
+
99
+ For the last row, do not work around it:
100
+
101
+ - **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
+ - **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.
104
+
88
105
  ## Custom filters
89
106
 
90
107
  | Filter | Example | Output |
@@ -113,7 +130,7 @@ Full mechanics + an EPC SEPA QR worked example: [`docs/ui/print-qr-codes.md`](..
113
130
 
114
131
  ### Amount in words
115
132
 
116
- The platform has no number-to-words filter. The `common-words` module (`modules/common-words`, a dependency to declare) ships snippets that write an amount or a plain quantity out in Ukrainian, English or German. Set the inputs, then include:
133
+ The platform has no number-to-words filter. The `common-words` module (`modules/common-words`, a dependency to declare) ships snippets that write an amount or a plain quantity out in Ukrainian, Russian, English or German. Set the inputs, then include:
117
134
 
118
135
  ```liquid
119
136
  {% assign words_amount = total %}
@@ -122,9 +139,17 @@ The platform has no number-to-words filter. The `common-words` module (`modules/
122
139
  {% include 'common-words.money' %}
123
140
  ```
124
141
 
125
- 1234.56 EUR prints «eintausendzweihundertvierunddreißig Euro 56 Cent». `words_amount` must be a numeric field, not text. `words_currency` is an ISO 4217 code in any case; an unlisted currency prints its code. `words_lang` is `uk` (default), `en` or `de`; an unknown code prints `[common-words: unknown language xx]`. `words_cap = true` capitalises the first letter. `common-words.money` only picks the language; `money_uk` / `money_en` / `money_de` can be included directly, and `common-words.quantity` writes a count of things («двадцять одна штука», «twenty-one pieces»): `words_number` (numeric, a fraction rounds half up), optional `words_unit` (forms joined by `|`: three for Ukrainian, two for English and German) and `words_gender` (`m` / `f` / `n`), plus `words_lang` and `words_cap` as above. The text lives in the module, so a fix or a new currency is a module update; a new language is a new `money_xx.html` plus a line in `money.html`.
142
+ 1234.56 EUR prints «eintausendzweihundertvierunddreißig Euro 56 Cent». `words_amount` must be a numeric field, not text. `words_currency` is an ISO 4217 code in any case; an unlisted currency prints its code. `words_lang` is `uk` (default), `ru`, `en` or `de`; an unknown code prints `[common-words: unknown language xx]`. `words_cap = true` capitalises the first letter. `common-words.money` only picks the language; `money_uk` / `money_ru` / `money_en` / `money_de` can be included directly, and `common-words.quantity` writes a count of things («двадцять одна штука», «twenty-one pieces»): `words_number` (numeric, a fraction rounds half up), optional `words_unit` (forms joined by `|`: three for Ukrainian and Russian, two for English and German) and `words_gender` (`m` / `f` / `n`), plus `words_lang` and `words_cap` as above; `quantity_uk` / `quantity_ru` can be included directly too. The text lives in the module, so a fix or a new currency is a module update; a new language is a new `money_xx.html` plus a line in `money.html`.
143
+
144
+ `quantity_uk` and `quantity_ru` also write a fraction in words. With `words_minor` + `words_minor_digits` (1–6) the fraction gets its own unit, as 1C's ЧислоПрописью does for a weight: «одна тысяча двести пятьдесят кг. пятьсот г.» (a zero fraction prints «ноль г.»). With `words_decimal = true` the number is a decimal fraction up to three digits, trailing zeros dropped: «двадцять одна ціла дванадцять сотих тонни». The unit comes in the 2–4 form, or as `words_unit_fraction` ('кілограма' for a Ukrainian masculine unit). The grammar is correct where 1C's is not: 1.001 is «одна ціла одна тисячна». English and German take neither input.
145
+
146
+ Snippets run on Scriban (print) and LiquidJS (preview), so write them for both:
126
147
 
127
- Snippets run on Scriban (print) and LiquidJS (preview), so write them for both: no `floor` on big numbers, no number taken from `split` / `slice`, plain `{% comment %}` without trim dashes.
148
+ - No `floor` on big numbers: Scriban's goes through a double and rounds them up.
149
+ - 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
+ - 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`.
152
+ - Plain `{% comment %}` without trim dashes.
128
153
 
129
154
  ## Module package layout
130
155
 
@@ -187,7 +212,7 @@ my_module/
187
212
  - **Writing an amount in words by hand.** Include `common-words.money` instead of a custom filter or lookup table.
188
213
  - **Module-installed template renamed in editor.** The editor preserves the original `template_cd` so `{% include %}` references stay intact — don't try to work around it.
189
214
 
190
- Module-installed print-template rows can't be deleted from the editor — uninstall the owning module to remove them.
215
+ Module-installed print-template rows can't be deleted from the editor. A template or snippet the package no longer declares is deleted on upgrade (dropped from `print_templates.json`, moved to another entity, or the file gone entirely); tenant-authored rows are never touched. To retire a template, remove it from the package, and remove or repoint any scheduled print subscription that names it: nothing references a template by key, so the subscription is not blocked, and its next run fails and suspends it.
191
216
 
192
217
  ## Reference
193
218
 
@@ -12,7 +12,8 @@ Lives in: `ui/queries.json`
12
12
  | Simple aggregation — "totals by X" | **Pre-built query** (`ui/queries.json`) |
13
13
  | Ad-hoc exploration by the user | **Query Builder** (runtime, no module authoring needed) |
14
14
  | Complex multi-dataset visualizations with charts/KPIs | **Report** (`ui/reports.json`) |
15
- | Full SQL with window functions, CTEs, cross-schema | **Report with stored procedure** |
15
+ | Totals grouped by a reference, or summing a value computed per row (lines by product, totals by VAT rate) | **Pre-built query**: `group` on the reference, `sum` over an `F` column of the entity (see *Calculated columns*) |
16
+ | Full SQL with window functions, CTEs, cross-schema, or a formula over an aggregate (`CEIL(SUM(qty) / box)`) | **Report with stored procedure** |
16
17
 
17
18
  Queries fill the gap between views (flat, no aggregation) and reports (heavy, need layout config).
18
19
 
@@ -131,6 +132,17 @@ Columns can traverse references up to 2 levels deep:
131
132
 
132
133
  The query engine automatically JOINs the referenced entities.
133
134
 
135
+ A reference path can be grouped or aggregated like any column: `{ "c": "product.unit", "agg": "group" }`.
136
+
137
+ ### Calculated columns
138
+
139
+ A value computed per row is aggregated in two ways:
140
+
141
+ - **An `F` column of the entity**, named by its code: `{ "c": "places", "agg": "sum" }`. Its formula may navigate references (`[qty] / [product].[box_qty]`). This is the way for anything that reads another entity.
142
+ - **An ad-hoc formula** in the query: `{ "formula": "[qty] * [price]", "agg": "sum", "alias": "amount" }`. It reads the root entity's own columns only: a navigation path fails the request.
143
+
144
+ The aggregate is applied to the row value, `SUM(formula)`. A formula over the aggregate (`SUM([a]) / COUNT([a])`, `CEIL(SUM([qty]) / [box_qty])`) is not expressible: in grouped mode a column without `agg` is dropped, and `SUM` / `COUNT` are not formula functions. That needs a stored procedure. The query is grouped when at least one column, plain or formula, has `agg: "group"`; there is no `grouping` switch. Aggregates without a `group` column are not a grand total: the request runs as an ungrouped row query and the aggregates are ignored, with no error.
145
+
134
146
  ### Filters
135
147
 
136
148
  Filters use the standard JSON filter format (see `references/filters.md`): `{ "c": column, "o": operator, "v": value }`.
@@ -306,7 +318,10 @@ Query names and descriptions are translated in each non-English `translations/<l
306
318
 
307
319
  - Putting SQL in the `query` object — **wrong**. Queries use the JSON format, not raw SQL. For raw SQL, use stored procedures in reports.
308
320
  - Using `"column"` instead of `"c"` in columns/filters — **wrong**. Use the short keys.
309
- - Forgetting `"agg"` on columns in grouped mode — every column needs an aggregation assignment.
321
+ - Forgetting `"agg"` on columns in grouped mode — a column without one is dropped from the result.
322
+ - Only aggregates, no `group` column, expecting one total row — **runs ungrouped**, the aggregates ignored and no error. Group by a constant-valued column, or total in a `G` column or a report.
323
+ - Writing a stored procedure to group lines and sum them — **wrong tool**. Grouping by a reference and summing an `F` column is a grouped query, and it runs under the user's entity, column and row-level security, which a stored procedure bypasses. Reach for SQL only for what the JSON cannot say (a formula over an aggregate, window functions, CTEs).
324
+ - Navigating a reference inside an ad-hoc `formula` (`[product].[box_qty]`) — **fails**. Declare an `F` column on the entity and aggregate it by code.
310
325
  - Referencing columns from unrelated entities — queries can only traverse the root entity's references.
311
326
  - Using `@TODAY`, `@CURRENT_USER`, or any `@placeholder` syntax in filter values — **wrong**. Queries do not support parameter substitution. Use the function operator instead: `{ "c": "due_date", "o": "f", "v": { "fn": "isPast" } }`.
312
327
  - Using a function as the value of a comparison operator (`<`, `>`, `=`, `!=`, `between`) — **wrong**. Functions are standalone conditions, valid only with `"o": "f"` (full detail + examples under "Dynamic date and user filters" above). `{ "c": "due_date", "o": "<", "v": { "fn": "isToday" } }` is invalid; use `{ "c": "due_date", "o": "f", "v": { "fn": "isPast" } }`.