@dforge-core/dforge-mcp 0.2.14 → 0.2.16
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 +239 -1
- package/dist/server.js +155 -7
- package/package.json +2 -2
- package/resources/docs/conventions.md +74 -1
- package/resources/docs/dsl-reference.md +58 -1
- package/resources/schemas/entity.schema.json +80 -2
- package/resources/schemas/folders.schema.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/README.md +67 -0
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +79 -0
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +23 -0
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +16 -0
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +35 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/data_views.json +20 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/folders.json +23 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/menus.json +21 -0
- package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +8 -12
- package/skills/dforge-mcp-author/references/action-dsl.md +113 -29
- package/skills/dforge-mcp-author/references/column-types.md +3 -2
- package/skills/dforge-mcp-author/references/data-views.md +2 -0
- package/skills/dforge-mcp-author/references/flags.md +41 -9
- package/skills/dforge-mcp-author/references/formulas.md +13 -1
- package/skills/dforge-mcp-author/references/jobs.md +4 -1
- package/skills/dforge-mcp-author/references/security.md +77 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +12 -0
- package/skills/dforge-module-build/SKILL.md +13 -2
|
@@ -98,7 +98,68 @@ preference:
|
|
|
98
98
|
|
|
99
99
|
### `canExecute:` — availability formula
|
|
100
100
|
|
|
101
|
-
A formula expression (same grammar as formula columns) that evaluates to true/false.
|
|
101
|
+
A formula expression (same grammar as formula columns) that evaluates to true/false.
|
|
102
|
+
|
|
103
|
+
**It is enforced on both sides.** The client evaluates it to decide whether the
|
|
104
|
+
button is **enabled** — the action stays in the toolbar either way, greyed out
|
|
105
|
+
rather than hidden. `action.execute` evaluates it again server-side over every
|
|
106
|
+
targeted record, before any write, and refuses the call with
|
|
107
|
+
`ACTION_EXECUTION_FAIL` when a record is in the wrong state. So it is a real gate,
|
|
108
|
+
not a UI hint — a caller hitting the API directly cannot approve an
|
|
109
|
+
already-rejected record.
|
|
110
|
+
|
|
111
|
+
Two consequences worth designing around:
|
|
112
|
+
|
|
113
|
+
- **`canExecute: false` is the idiom for an action only automation may fire.** The
|
|
114
|
+
server refuses it through `action.execute`; triggers and scheduled jobs invoke
|
|
115
|
+
the script engine directly and are unaffected.
|
|
116
|
+
- **Both evaluators have gaps, and both fail open.** The server is a scalar walker
|
|
117
|
+
— comparisons between fields and literals, combined with `AND` / `OR` / `NOT`,
|
|
118
|
+
nothing else — and lets anything richer through. The client implements the full documented formula
|
|
119
|
+
function set, so ordinary functions are fine there; it throws (and its `catch`
|
|
120
|
+
**enables** the button) on **ref navigation** (`[ref].target`), on the set
|
|
121
|
+
aggregates **`SUM` / `COUNT` / `AVG`** (deliberately not formula functions —
|
|
122
|
+
counting is a Generated `G` column's job), and on **any name it does not know, a
|
|
123
|
+
typo included** — a `canExecute` is never validated against the function set at
|
|
124
|
+
install, so `TOADY()` ships and enables the button for every record.
|
|
125
|
+
- **`$[setting]` references are a separate case.** They do not throw: the client's
|
|
126
|
+
resolver is stubbed to `null`, so the predicate resolves however that operator
|
|
127
|
+
treats null (an ordered comparison against null is `false`, so it tends to
|
|
128
|
+
*disable* the button), while the server has no settings resolver and fails open.
|
|
129
|
+
Keep settings out of `canExecute` and read them in `execute:` instead.
|
|
130
|
+
|
|
131
|
+
| tier | construct | client | server |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
|
|
134
|
+
| 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
|
|
135
|
+
| 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
|
|
136
|
+
| — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
|
|
137
|
+
|
|
138
|
+
**A bare boolean column is not tier 1.** The walker evaluates conditions, not
|
|
139
|
+
values — a field is only resolvable as an operand of a comparison — so
|
|
140
|
+
`canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
|
|
141
|
+
`[is_active] = true` and `NOT ([is_blocked] = true)`. (`canExecute: false` is fine:
|
|
142
|
+
a bare literal is a complete condition.)
|
|
143
|
+
|
|
144
|
+
Mixing tier 1 and tier 2 is the normal, healthy shape: the client evaluates the
|
|
145
|
+
whole thing so the button state is right, and the server short-circuits `AND` /
|
|
146
|
+
`OR` so the tier-1 half is still enforced.
|
|
147
|
+
|
|
148
|
+
**Tier 3 is what breaks.** The server short-circuits, the client does not — so
|
|
149
|
+
`[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
|
|
150
|
+
evaluates `COUNT` in the browser, throws, and the button is **enabled**, then the
|
|
151
|
+
server refuses the call: an error where a greyed-out button was wanted. And a
|
|
152
|
+
`canExecute` is not validated against the function set at install, so that spelling
|
|
153
|
+
ships silently. Count with a `G` column and compare the scalar
|
|
154
|
+
(`[detail_count] > 0`); denormalise instead of navigating; put anything genuinely
|
|
155
|
+
unexpressible in `execute:` behind `error()`.
|
|
156
|
+
|
|
157
|
+
> Never make `canExecute` the *only* guard for something that must hold. Between
|
|
158
|
+
> the fail-open rules on both sides, the enqueue-only check on background runs, and
|
|
159
|
+
> the fact that the client evaluates over the grid row (which does not carry
|
|
160
|
+
> extension columns) while the server evaluates over a full record load, the
|
|
161
|
+
> authoritative check belongs in `execute:` — validate and `error()` before the
|
|
162
|
+
> first write.
|
|
102
163
|
|
|
103
164
|
```
|
|
104
165
|
canExecute:
|
|
@@ -117,7 +178,7 @@ canExecute:
|
|
|
117
178
|
[quantity] >= 0
|
|
118
179
|
```
|
|
119
180
|
|
|
120
|
-
Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc.
|
|
181
|
+
Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc. — but see the fail-open note above: only the scalar comparison subset is enforced server-side.
|
|
121
182
|
|
|
122
183
|
**Note**: `canExecute` uses single `=` for equality (formula syntax), NOT `==` (JS syntax).
|
|
123
184
|
|
|
@@ -535,7 +596,9 @@ In `batch` mode, access individual records with `__records.get(i)`, the count wi
|
|
|
535
596
|
|
|
536
597
|
## Async actions
|
|
537
598
|
|
|
538
|
-
For long-running actions, set `"isAsync": true` in `ui/actions.json`.
|
|
599
|
+
For long-running actions, set `"isAsync": true` in `ui/actions.json`. That **permits** background execution: the call is queued as a `background_action` row and picked up by `BackgroundActionService` (a hosted `BackgroundService` polling every ~10s — there is no Hangfire in dForge), and the user gets a completion notification via SSE. The DSL syntax is the same.
|
|
600
|
+
|
|
601
|
+
`isAsync` does not force it. `action.execute` carries its own `async` argument and the server branches on that, so a parameterless action is queued directly while one with parameters is offered to the user as both "Run" (inline) and "Run in Background". Note that a queued run has **no wrapping transaction** — see *Atomicity* below.
|
|
539
602
|
|
|
540
603
|
## Registering the action
|
|
541
604
|
|
|
@@ -576,37 +639,58 @@ In `ui/actions.json`:
|
|
|
576
639
|
| `entityCode` | Yes | Which entity this action applies to |
|
|
577
640
|
| `executionMode` | Yes | `"single"` (default) or `"each"` — runs per record, DSL uses `[field]`; or `"batch"` — runs once, DSL uses `for x in records { ... }` with `__records` |
|
|
578
641
|
| `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
|
|
579
|
-
| `isTransacted` | Recommended | `true` = the
|
|
642
|
+
| `isTransacted` | Recommended | `true` = the whole run is **one transaction**: the first failure aborts it *and* rolls back everything already written. `false` = no transaction, and what happens next depends on the mode — `single`/`each` report the failure and carry on with the next record, keeping what succeeded; a `batch` script is one invocation, so a failure just ends the run there with its earlier writes committed and its `[field]` assignments never flushed. **Defaults to `true`** when omitted — see *Atomicity* below |
|
|
580
643
|
| `orderNum` | Recommended | Display order in the action menu |
|
|
581
|
-
| `isAsync` | Optional | `true`
|
|
644
|
+
| `isAsync` | Optional | `true` **permits** background execution — it does not force it. A parameterless action is queued directly; one with params is offered to the user as both "Run" and "Run in Background". Queued runs get a completion notification |
|
|
645
|
+
|
|
646
|
+
## Atomicity
|
|
647
|
+
|
|
648
|
+
`isTransacted: true` makes the run **one transaction**, in both execution modes.
|
|
649
|
+
The first failing record aborts the run and every write the run made — including
|
|
650
|
+
those for records that already succeeded — is rolled back. It covers the script's
|
|
651
|
+
own `insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
|
|
652
|
+
assignments. It does not depend on the entity carrying `auditHistory`.
|
|
582
653
|
|
|
583
|
-
|
|
654
|
+
`isTransacted: false` is the opposite contract — no transaction — and what it means
|
|
655
|
+
depends on the mode:
|
|
584
656
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
657
|
+
- **`single` / `each`**: each record stands alone. The failure is reported, the loop
|
|
658
|
+
carries on with the next record, and what the earlier records wrote is kept.
|
|
659
|
+
- **`batch`**: there is one script invocation and no loop to continue, so a failure
|
|
660
|
+
simply ends the run. Whatever the script had already written with `insert()` /
|
|
661
|
+
`update()` is committed, and the pending `[field] = …` assignments are never
|
|
662
|
+
flushed. There is nothing "independent" about it — which is why `batch` +
|
|
663
|
+
`isTransacted: false` is a shape to avoid.
|
|
588
664
|
|
|
589
|
-
| path |
|
|
665
|
+
| path | `isTransacted: true` | `isTransacted: false` |
|
|
590
666
|
|---|---|---|
|
|
591
|
-
| Sync execute,
|
|
592
|
-
| Sync execute,
|
|
593
|
-
|
|
|
594
|
-
|
|
|
595
|
-
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
667
|
+
| Sync execute, `single` | whole selection rolled back | writes stay committed |
|
|
668
|
+
| Sync execute, `batch` | whole run rolled back | writes stay committed |
|
|
669
|
+
| Scheduled job | rolled back | rolled back (the dispatcher owns the transaction) |
|
|
670
|
+
| **Queued to background**, `single` / `each` | **stays committed** (run still aborts at the first failure) | **stays committed** (run continues) |
|
|
671
|
+
| **Queued to background**, `batch` | **stays committed** (flag not read at all) | **stays committed** (same) |
|
|
672
|
+
|
|
673
|
+
**The background row is the exception and it is a known platform gap.** The
|
|
674
|
+
background worker opens no transaction, so an action that is atomic when run
|
|
675
|
+
inline is not when the same call is queued. The rollback half is lost outright;
|
|
676
|
+
what survives depends on the mode. The worker's `single` / `each` loop still reads
|
|
677
|
+
`isTransacted` to decide whether to abort after the first failed record or carry
|
|
678
|
+
on, so there `true` means "stop here", not "undo". Its `batch` path never reads the
|
|
679
|
+
flag — one invocation, ending on failure either way — so a queued `batch` action
|
|
680
|
+
gets nothing from `isTransacted` at all. And which one happens is decided by
|
|
681
|
+
the *request*, not by the manifest: `isAsync: true` only permits queuing, and an
|
|
682
|
+
action with parameters is offered to the user as both "Run" (inline, transactional)
|
|
683
|
+
and "Run in Background" (queued, not). So an `isAsync` action cannot be assumed to
|
|
684
|
+
be either.
|
|
685
|
+
|
|
686
|
+
**Two things that follow for how you write actions:**
|
|
687
|
+
|
|
688
|
+
1. **Declare `isTransacted: true`** — which is also the default — for anything that
|
|
689
|
+
writes more than one row. Do *not* reach for `auditHistory` as an atomicity
|
|
690
|
+
switch; that was a workaround for a defect that no longer exists.
|
|
691
|
+
2. **Still validate up front.** Call `error()` before the first `insert()` /
|
|
692
|
+
`update()` where you can, so a background-queued run has nothing to unwind
|
|
693
|
+
either. This is the only guard that holds on every path.
|
|
610
694
|
|
|
611
695
|
## SQL in query() — important notes
|
|
612
696
|
|
|
@@ -36,8 +36,9 @@ Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column*
|
|
|
36
36
|
The example below is a **required** relation, so both halves carry `M`. For an optional
|
|
37
37
|
one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
|
|
38
38
|
at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
|
|
39
|
-
virtual Reference half
|
|
40
|
-
|
|
39
|
+
virtual Reference half (at the entity level; a per-view override behaves differently, see
|
|
40
|
+
`flags.md`), leaving it there over an optional FK reads as a required field to the next
|
|
41
|
+
author. `dforge_entity_reference_add({ required })` emits both halves in step.
|
|
41
42
|
|
|
42
43
|
```json
|
|
43
44
|
// Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
|
|
@@ -4,6 +4,8 @@ Data views define how users see and interact with entity data. One entity can ha
|
|
|
4
4
|
|
|
5
5
|
Lives in: `ui/data_views.json`
|
|
6
6
|
|
|
7
|
+
> **Not to be confused with entity views.** An *entity view* (`views` on an entity file) is column-level **security** — which columns a folder may see at all — and is what `folders.json`'s `viewName` binds. A *data view* is a presentation of an entity, referenced by a menu's `dataViewCode`. A third thing, `isView` + `viewSql` on an entity, means the entity is backed by a SQL view. See `security.md` → Column-level security.
|
|
8
|
+
|
|
7
9
|
## Structure
|
|
8
10
|
|
|
9
11
|
```json
|
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Column flags are single-letter codes concatenated into a string. Order doesn't matter — `"VEM"` and `"EMV"` are equivalent.
|
|
4
4
|
|
|
5
|
-
Valid flags regex: `^[
|
|
5
|
+
Valid flags regex: `^[VIEM]*$`
|
|
6
6
|
|
|
7
7
|
## Flag letters
|
|
8
8
|
|
|
9
9
|
| Letter | Name | Meaning |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
|
|
12
|
-
| `I` |
|
|
12
|
+
| `I` | Identity | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users, excluded from required validation. |
|
|
13
13
|
| `E` | Editable | User can modify the value in the UI. Omit for read-only. |
|
|
14
14
|
| `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
|
|
15
|
-
| `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
|
|
16
15
|
|
|
17
|
-
**Only these
|
|
16
|
+
**Only these 4 letters are valid.** The platform rejects any other characters.
|
|
17
|
+
> **There is no `H` flag.** Earlier drafts listed one, described as "stronger than omitting `V`", and nothing ever read it — a column with `flags: "H"` is hidden because the string contains no `V`, not because it contains `H`. To hide a column, omit `V`. To hide it from specific folders (and make it unqueryable there), use an entity view. To render it as a hidden input, use `fieldTypeCd: "hidden"`.
|
|
18
|
+
|
|
19
|
+
> **The same letters override per entity view.** `views.<v>.columns.<cd>.flags` re-flags a column for the folders bound to that view, while the entity keeps its own flags everywhere else. Same `^[VIEM]*$` vocabulary — see "Per-view overrides" below. See `security.md` → Column-level security.
|
|
18
20
|
|
|
19
21
|
## `M` is the platform's one notion of "required"
|
|
20
22
|
|
|
@@ -31,7 +33,8 @@ Four consequences worth knowing before you type a flag:
|
|
|
31
33
|
entity and every offending field. It's a contradiction only you can resolve: drop
|
|
32
34
|
`M` if the column is optional, or drop `isNullable` if it is required.
|
|
33
35
|
- **`M` is inert on virtual (`R`/`S`/`F`) and identity columns** — nullability is
|
|
34
|
-
meaningless where there is no physical column.
|
|
36
|
+
meaningless where there is no physical column. (At the *entity* level; a per-view
|
|
37
|
+
override behaves differently — see "Per-view overrides".) It won't fail, but `VEM` on a
|
|
35
38
|
Reference over an *optional* FK reads as a required field to the next author, which
|
|
36
39
|
is exactly how these get out of step. Match the Reference to its FK.
|
|
37
40
|
- **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
|
|
@@ -41,6 +44,33 @@ Four consequences worth knowing before you type a flag:
|
|
|
41
44
|
|
|
42
45
|
Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
|
|
43
46
|
|
|
47
|
+
## Per-view overrides
|
|
48
|
+
|
|
49
|
+
`views.<v>.columns.<cd>.flags` re-flags one column for the folders bound to that view. The letters are the same four, and `M` means the same thing — the installer folds it into **that override's** `isNullable`.
|
|
50
|
+
|
|
51
|
+
Because a view writes no DDL, the requiredness `M` produces there is runtime-only. That is how you express **"nullable in the database, mandatory in this view"**, which no entity-level flag can say:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
"views": {
|
|
55
|
+
"compliance": {
|
|
56
|
+
"columns": {
|
|
57
|
+
"party_id": {},
|
|
58
|
+
"party_name": { "flags": "V" },
|
|
59
|
+
"vat_no": { "flags": "VEM" },
|
|
60
|
+
"salary": { "flags": "" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Three rules to keep straight:
|
|
67
|
+
|
|
68
|
+
- **The letters replace, they don't merge.** The cascade is `COALESCE(view, entity)`. A non-empty string wins outright, so `{ "flags": "V" }` is read-only there however the entity flagged the column.
|
|
69
|
+
- **`""` is a real value, omission is not.** An empty string means "no flags here" — neither visible nor editable. To inherit, leave the key out; `"flags": null` fails schema validation, since the property is typed `string`. (To inherit *everything*, set the whole column override to `{}` or `null`.)
|
|
70
|
+
- **The `M` fold is one-directional.** Dropping `M` from a view's flags does NOT make the column optional — `isNullable` still `COALESCE`s to the entity value. Write `"isNullable": true` to loosen it, and only where the database actually allows null, or the save trades a clean validation error for a raw constraint violation.
|
|
71
|
+
|
|
72
|
+
Unlike the entity level, `M` here is *not* inert on virtual or identity columns: the fold only reaches the runtime required check, so a view can mark a Reference required. (Identity columns stay excluded — they are never asked of the user.)
|
|
73
|
+
|
|
44
74
|
## Common column flag combinations
|
|
45
75
|
|
|
46
76
|
| Flags | Meaning | Use case |
|
|
@@ -50,7 +80,7 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
|
|
|
50
80
|
| `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
|
|
51
81
|
| `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
|
|
52
82
|
| `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
|
|
53
|
-
| `"I"` |
|
|
83
|
+
| `"I"` | Identity | Trait-provided columns (PK, audit timestamps). Platform-managed. |
|
|
54
84
|
| `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
|
|
55
85
|
|
|
56
86
|
## Rules
|
|
@@ -60,9 +90,9 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
|
|
|
60
90
|
2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
|
|
61
91
|
two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
|
|
62
92
|
3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
|
|
63
|
-
4. **Trait columns** (PK, audit): use `"I"` (
|
|
93
|
+
4. **Trait columns** (PK, audit): use `"I"` (identity). These are set automatically by `traits: ["identity", "audit"]`.
|
|
64
94
|
5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
|
|
65
|
-
6. **Don't mix `I` with `V` or `E`** —
|
|
95
|
+
6. **Don't mix `I` with `V` or `E`** — identity columns are not user-facing.
|
|
66
96
|
|
|
67
97
|
## Entity rights flags (different namespace — roles only)
|
|
68
98
|
|
|
@@ -79,7 +109,7 @@ These letters appear in role `rights` declarations, **NOT** on column flags. Don
|
|
|
79
109
|
|
|
80
110
|
So `"SIUD"` on a role grants full CRUD; `"SI"` grants read + create only.
|
|
81
111
|
|
|
82
|
-
**Note**: the `I` in column flags (
|
|
112
|
+
**Note**: the `I` in column flags (Identity) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
|
|
83
113
|
|
|
84
114
|
## How uniqueness, PKs, and search work
|
|
85
115
|
|
|
@@ -101,4 +131,6 @@ These are **NOT** flag letters. They're separate properties on the column defini
|
|
|
101
131
|
- Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
|
|
102
132
|
- Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
|
|
103
133
|
- Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
|
|
134
|
+
- Expecting a per-view `{ "flags": "VE" }` to make a column **optional** because it drops `M` — it doesn't. `isNullable` still inherits; write `"isNullable": true`.
|
|
135
|
+
- Writing `"flags": null` in a view override to inherit — fails validation. Omit the key.
|
|
104
136
|
- Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
|
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
Formulas are used in:
|
|
4
4
|
|
|
5
5
|
- **Formula columns** (`columnType: "F"`) — computed values
|
|
6
|
-
- **
|
|
6
|
+
- **Per-view formula overrides** (`views.<v>.columns.<cd>.formula`) — one entity view
|
|
7
|
+
computing a column differently from the entity. Allowed **only** on a column the
|
|
8
|
+
entity declares as `columnType: "F"`; on any other column the install FAILS, because
|
|
9
|
+
there that field holds the SQL default and a view cannot change a physical column's
|
|
10
|
+
`DEFAULT`. (There is no per-view default value for a normal column.) Same rules as an
|
|
11
|
+
`F` column otherwise: it must parse, call only implemented functions, and not
|
|
12
|
+
aggregate over a 1:N set. See `security.md` → Column-level security.
|
|
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).
|
|
7
19
|
- **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.
|
|
8
20
|
- **Filter expressions** (partially)
|
|
9
21
|
- **Validation expressions** (partially)
|
|
@@ -74,7 +74,10 @@ execute:
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
Gate the action to automation only with `canExecute: false`. The button is left in
|
|
78
|
+
the toolbar but permanently **disabled**, and `action.execute` refuses the call
|
|
79
|
+
server-side, while the scheduler — which invokes the script engine directly — is
|
|
80
|
+
unaffected:
|
|
78
81
|
|
|
79
82
|
```
|
|
80
83
|
canExecute:
|
|
@@ -213,7 +213,7 @@ Lives in: `ui/folders.json`
|
|
|
213
213
|
|
|
214
214
|
| Property | Type | Description |
|
|
215
215
|
|---|---|---|
|
|
216
|
-
| `viewName` | string |
|
|
216
|
+
| `viewName` | string | Entity view to bind — a key under the entity's `views` (see [Column-level security](#column-level-security-entity-views)), **not** a `ui/data_views.json` code. `"default"` means "no view" (full column set). |
|
|
217
217
|
| `quickAdd` | boolean | Show "+" quick-add button (`true` for main entities, `false` for detail/line items) |
|
|
218
218
|
| `rowFilter` | object | Row-level filter scoping which records are visible in this folder |
|
|
219
219
|
|
|
@@ -226,6 +226,82 @@ Row filters use a compact filter object with:
|
|
|
226
226
|
|
|
227
227
|
This is how **row-level security** works in the module package — subfolders with `rowFilter` restrict which rows are visible. Users assigned to "Central Warehouse" only see stock where `warehouse_id = 2001`.
|
|
228
228
|
|
|
229
|
+
## Column-level security (entity views)
|
|
230
|
+
|
|
231
|
+
> Worked example: `dforge://example/column-security/` — one entity, two folders, two
|
|
232
|
+
> column sets. Its README walks the overrides and the ways a view fails the install.
|
|
233
|
+
|
|
234
|
+
Layer 2 of the model. A **entity view** is a named subset of an entity's columns, declared on the entity itself; a folder binds one per entity. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there.
|
|
235
|
+
|
|
236
|
+
Two halves, both required:
|
|
237
|
+
|
|
238
|
+
| File | Key | Effect |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
|
|
241
|
+
| `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"fields": { "product_id": {}, "name": {}, "price": {}, "cost": {}, "quantity": {} },
|
|
246
|
+
"views": {
|
|
247
|
+
"accountant": {
|
|
248
|
+
"columns": {
|
|
249
|
+
"product_id": {},
|
|
250
|
+
"name": { "flags": "V" },
|
|
251
|
+
"price": { "flags": "VE" },
|
|
252
|
+
"cost": { "flags": "V" }
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
"storekeeper": {
|
|
256
|
+
"columns": {
|
|
257
|
+
"product_id": {},
|
|
258
|
+
"name": { "flags": "V" },
|
|
259
|
+
"quantity": { "flags": "VE" }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
"entities": { "product": { "viewName": "accountant", "quickAdd": true } }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The storekeeper folder cannot see `price` or `cost` at all — not on screen, not through a filter, not in an export.
|
|
271
|
+
|
|
272
|
+
**Per-column overrides** (all optional, each falls back to the entity-level value): `flags`, `orderNum`, `isNullable`, `editMask`, `displayFmt`, `refFilter`, `params`, `formula`. `{}` means "expose this column, override nothing".
|
|
273
|
+
|
|
274
|
+
> **An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE`, which skips NULL only — so `"flags": ""` means "no flags in this view" (a column neither visible nor editable) and `"params": {}` suppresses the entity-level params rather than inheriting them. Omit the key entirely to inherit — `"flags": null` fails schema validation. See `flags.md` for the flag letters.
|
|
275
|
+
|
|
276
|
+
> **`M` works in a view override too**, folding into that override's `isNullable`. Since a view writes no DDL, the requiredness is runtime-only — which is how a column nullable in the database is made mandatory inside one view: `{ "flags": "VEM" }`. The fold is one-directional: dropping `M` does not loosen the column, because `isNullable` still inherits. See `flags.md` → Per-view overrides.
|
|
277
|
+
|
|
278
|
+
`formula` lets one view compute a column differently from the entity — same DSL as a field's `formula`, and **only on a column the entity declares as `columnType: "F"`**; elsewhere the install fails, because there that field holds the SQL default and a view cannot change a physical column's `DEFAULT`. There is no per-view default value for a normal column. See `formulas.md`.
|
|
279
|
+
|
|
280
|
+
### Rules the installer enforces
|
|
281
|
+
|
|
282
|
+
Each of these is an install-time error, and each is silent at runtime if it slips through — which is why `dforge_module_validate` checks them offline:
|
|
283
|
+
|
|
284
|
+
- A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
|
|
285
|
+
- A view must list the entity's **primary key** — records are addressed by it. To keep it off screen, drop `V` from its flags instead of omitting it.
|
|
286
|
+
- A column code that doesn't exist on the entity, a view with **no columns**, the same column listed twice under different casing, or two views on one entity whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
|
|
287
|
+
- Views must be declared on the entity's **own** module. An `extends` extension declaring `views` is rejected — a view enumerates the complete column set, which an extension does not own.
|
|
288
|
+
- A `viewName` that no view declares, or one naming a view with no columns, **fails the install**. It does not fall back: the fallback is the entity's full column set, so a typo would quietly *unrestrict* the folder.
|
|
289
|
+
- Columns removed from a view are reaped on upgrade — that is the security change the author asked for. Whole views are not: `entity_view` also holds tenant views from the card-layout editor, and the installer cannot tell them apart.
|
|
290
|
+
|
|
291
|
+
### `"default"` is the one exempt name
|
|
292
|
+
|
|
293
|
+
`"viewName": "default"` means **no view** (full column set) and declares nothing. It is what every shipped module writes, and what the runtime auto-creates for an entity reached without a folder binding — so it is exempt from the checks above. A module that really declares a view named `default` still gets it bound.
|
|
294
|
+
|
|
295
|
+
### Don't confuse the three "views"
|
|
296
|
+
|
|
297
|
+
| Term | Where | What it is |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| **Entity view** | `entities/*.json` → `views` | Column-level security (this section) |
|
|
300
|
+
| **Data view** | `ui/data_views.json` | A grid / kanban / calendar over an entity (`data-views.md`) |
|
|
301
|
+
| **SQL view** | `entities/*.json` → `isView` + `viewSql` | An entity backed by a SQL view instead of a table |
|
|
302
|
+
|
|
303
|
+
`viewName` in `folders.json` binds the first. Menus' `dataViewCode` references the second.
|
|
304
|
+
|
|
229
305
|
### When to use subfolders
|
|
230
306
|
|
|
231
307
|
- **Multi-location modules** (warehouses, offices, branches) — one subfolder per location, filtered by location FK
|
|
@@ -105,6 +105,8 @@ For each entity:
|
|
|
105
105
|
## Security
|
|
106
106
|
|
|
107
107
|
- [ ] `security/roles.json` exists (at least one role)
|
|
108
|
+
- [ ] Every entity view (`views.<v>.columns` on an entity) lists the entity's **primary key**, names only real columns, and has at least one column — each of these fails the install
|
|
109
|
+
- [ ] Every `viewName` in `ui/folders.json` is either `"default"` (= no view) or a view that entity actually declares — an unresolved name fails the install rather than falling back
|
|
108
110
|
- [ ] Every role has `description` and `rights` (both required; no `label` field in `roles.json` — the `roles.schema.json` sets `additionalProperties: false`, so any extra field including `label` is rejected here). `description` is the English display-name fallback; **localized role labels live in the translation files** (`roles.<code>.label`) and are completeness-enforced — see Translations below.
|
|
109
111
|
- [ ] The field name is `rights`, NOT `entityRights`
|
|
110
112
|
- [ ] Rights strings use only valid letters: `SIUDC` for entities, `E` for actions/reports/folders
|
|
@@ -119,6 +121,16 @@ For each action:
|
|
|
119
121
|
- [ ] 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)
|
|
120
122
|
- [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
|
|
121
123
|
- [ ] `canExecute:` is a valid formula expression
|
|
124
|
+
- [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
|
|
125
|
+
guard is expressed in the server-enforced subset — **comparisons** between
|
|
126
|
+
fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
|
|
127
|
+
(`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
|
|
128
|
+
`$[setting]` reads are fail-open server-side and are advisory only
|
|
129
|
+
- [ ] No **bare boolean field** as a whole condition — `[is_active]` and
|
|
130
|
+
`NOT [is_blocked]` are not enforced (a field is only an operand). Write
|
|
131
|
+
`[is_active] = true` / `NOT ([is_blocked] = true)`
|
|
132
|
+
- [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
|
|
133
|
+
`canExecute` alone is never the authoritative guard
|
|
122
134
|
- [ ] `execute:` uses only documented built-in functions
|
|
123
135
|
- [ ] Referenced entities exist
|
|
124
136
|
- [ ] Referenced parameters are declared in `params:`
|
|
@@ -31,6 +31,7 @@ flags, or column patterns.
|
|
|
31
31
|
| Add a grid / list view | `dforge://reference/data-views`, `dforge://example/ui/data_views.json` | — |
|
|
32
32
|
| Add a matrix (pivot) view | `dforge://reference/data-views` (§Matrix), `dforge://example/matrix-budget/ui/data_views.json`, `dforge://example/matrix-budget/entities/budget_line.json` | — |
|
|
33
33
|
| Add kanban / calendar / tree-grid / master-detail | `dforge://reference/data-views` | `dforge://schema/data-views` |
|
|
34
|
+
| Hide columns from a user type (column-level security) | `dforge://reference/security` (§Column-level security), `dforge://example/column-security/entities/product.json`, `dforge://example/column-security/ui/folders.json` | — |
|
|
34
35
|
| Add a menu | `dforge://reference/menus`, `dforge://example/ui/menus.json` | — |
|
|
35
36
|
| Add an action | `dforge://reference/action-dsl`, `dforge://example/logic/actions/mark_done.dsl`, `dforge://example/ui/actions.json` | `dforge://docs/dsl` |
|
|
36
37
|
| Add a trigger | — | `dforge://schema/triggers`; condition syntax in `dforge://docs/dsl` |
|
|
@@ -204,8 +205,11 @@ per entity, so you'll often `view_modify` rather than `view_add`.
|
|
|
204
205
|
|
|
205
206
|
View codes are semantic — the entity name (`feedback_item`), a plural
|
|
206
207
|
(`invoices`), or descriptive (`invoices_kanban`). **Never use the literal code
|
|
207
|
-
`default
|
|
208
|
-
|
|
208
|
+
`default`.** Note that `ui/folders.json`'s `viewName` is a different axis
|
|
209
|
+
entirely: it binds an *entity view* (column-level security, declared under
|
|
210
|
+
`views` on the entity file), not a data view code. `"default"` there means "no
|
|
211
|
+
entity view — show the full column set", and any other name the entity does not
|
|
212
|
+
declare **fails the install**. See `references/security.md`.
|
|
209
213
|
|
|
210
214
|
Then wire menus with `dforge_menu_add` — a section node per group, leaves
|
|
211
215
|
pointing at the views.
|
|
@@ -273,6 +277,13 @@ stated trigger; every report's params sit on a dataset.
|
|
|
273
277
|
4. **Show the rights matrix as a table** (rows = entities/actions/reports,
|
|
274
278
|
columns = roles, cells = rights strings), each cell explained by the verb it
|
|
275
279
|
maps to. Get sign-off.
|
|
280
|
+
5. **Column-level security only when the intake asks for it.** Roles grant rights
|
|
281
|
+
per entity, not per field. When a user type must not see specific *columns*
|
|
282
|
+
(salaries, cost prices, personal data), that is an **entity view**: declare
|
|
283
|
+
`views.<name>.columns` on the entity and bind it with `viewName` on the folder
|
|
284
|
+
that user type works in. Don't reach for it otherwise — a view is the complete
|
|
285
|
+
column list, so it is one more list to keep in step with the fields. Rules and
|
|
286
|
+
the worked example: `references/security.md` → Column-level security.
|
|
276
287
|
5. `dforge_role_add` for new roles; `dforge_role_right_set` per grant when
|
|
277
288
|
amending an existing one.
|
|
278
289
|
|