@dforge-core/dforge-mcp 0.2.14 → 0.2.15
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 +83 -1
- package/package.json +1 -1
- package/resources/docs/dsl-reference.md +58 -1
- package/skills/dforge-mcp-author/references/action-dsl.md +113 -29
- package/skills/dforge-mcp-author/references/formulas.md +6 -1
- package/skills/dforge-mcp-author/references/jobs.md +4 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,89 @@ 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
|
-
## 0.2.
|
|
7
|
+
## 0.2.15
|
|
8
|
+
|
|
9
|
+
`canExecute` became a real server-side gate and `isTransacted` began opening a real
|
|
10
|
+
transaction (platform change `5398a48b0`). Both had been documented here as the
|
|
11
|
+
opposite — "a UI hint" and "does not open a transaction" — so this release is a
|
|
12
|
+
correction pass across the authoring guidance, with no tool or schema changes.
|
|
13
|
+
|
|
14
|
+
### Changed — `canExecute` is enforced, with a fail-open subset
|
|
15
|
+
|
|
16
|
+
`action.execute` now evaluates the stored `can_execute_parsed` AST over every targeted
|
|
17
|
+
record before any write and refuses the call with `ACTION_EXECUTION_FAIL`. So
|
|
18
|
+
`canExecute: false` is a genuine "only automation may fire this" — the scheduler and
|
|
19
|
+
triggers invoke the script engine directly and still bypass it.
|
|
20
|
+
|
|
21
|
+
The server evaluator is a scalar walker, not the formula engine, and the client has its
|
|
22
|
+
own separate gaps, so `references/action-dsl.md` and `dsl-reference.md` now carry a tier
|
|
23
|
+
table instead of a single list:
|
|
24
|
+
|
|
25
|
+
| tier | construct | client | server |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
|
|
28
|
+
| 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
|
|
29
|
+
| 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
|
|
30
|
+
| — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
|
|
31
|
+
|
|
32
|
+
Four things in there are easy to get wrong and are called out explicitly:
|
|
33
|
+
|
|
34
|
+
- **A bare boolean field is not enforced.** The walker evaluates conditions, not values —
|
|
35
|
+
a field resolves only as an operand of a comparison — so `canExecute: [is_active]` and
|
|
36
|
+
`NOT [is_blocked]` fail open. Write `[is_active] = true` and `NOT ([is_blocked] = true)`.
|
|
37
|
+
- **A misspelled function ships silently.** A `canExecute` is never validated against the
|
|
38
|
+
function set at install (`FindUnknownFunctions` is wired only into the formula-column
|
|
39
|
+
path), so `TOADY()` installs and enables the button for every record.
|
|
40
|
+
- **Tier 3 mixed into a predicate is worse than useless**, because the two sides disagree
|
|
41
|
+
about short-circuiting: the server's `AND` / `OR` short-circuit, the client's evaluator
|
|
42
|
+
does not. `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record throws
|
|
43
|
+
in the browser, is caught, and **enables** the button — then the server refuses the call.
|
|
44
|
+
An error where a greyed-out button was wanted. Count with a Generated (`G`) column and
|
|
45
|
+
compare the scalar. Mixing tier 1 with tier 2 is fine and is the normal shape.
|
|
46
|
+
- **Settings do not work in `canExecute` at all** — the client resolver is stubbed to
|
|
47
|
+
`null` and the server has none, so the guard is unenforced server-side and tends to jam
|
|
48
|
+
the button *shut* client-side. Read them in `execute:`.
|
|
49
|
+
|
|
50
|
+
`references/jobs.md` also stops promising that `canExecute: false` hides the button: the
|
|
51
|
+
action stays in the toolbar, permanently disabled.
|
|
52
|
+
|
|
53
|
+
### Changed — `isTransacted` opens a transaction, and defaults to `true`
|
|
54
|
+
|
|
55
|
+
The *Don't assume your action is atomic* section is replaced by *Atomicity*. `true` now
|
|
56
|
+
makes the whole run one transaction in both execution modes, covering the script's own
|
|
57
|
+
`insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
|
|
58
|
+
assignments, and it no longer depends on the entity carrying `auditHistory` — advice to
|
|
59
|
+
reach for that trait as an atomicity switch is gone.
|
|
60
|
+
|
|
61
|
+
`false` is documented per mode, because "failures are isolated" only ever made sense where
|
|
62
|
+
there is a loop: `single` / `each` report the failure and continue with the next record,
|
|
63
|
+
while a `batch` script is one invocation, so a failure just ends the run with its earlier
|
|
64
|
+
writes committed and its `[field]` assignments never flushed.
|
|
65
|
+
|
|
66
|
+
The background path is the exception, and it splits by mode too: the worker opens no
|
|
67
|
+
transaction, so nothing is ever undone; its `single` / `each` loop still reads the flag for
|
|
68
|
+
control flow, while its `batch` path never reads it at all — a queued `batch` action gets
|
|
69
|
+
nothing from `isTransacted` in either direction.
|
|
70
|
+
|
|
71
|
+
### Fixed — `isAsync` does not force background execution, and there is no Hangfire
|
|
72
|
+
|
|
73
|
+
`isAsync: true` only *permits* queuing. The server branches on the `async` argument the
|
|
74
|
+
request carries, so a parameterless action is queued directly while one with parameters is
|
|
75
|
+
offered to the user as both "Run" (inline, transactional) and "Run in Background". Whether
|
|
76
|
+
an action is atomic is therefore not answerable from the manifest alone.
|
|
77
|
+
|
|
78
|
+
Two claims that dForge runs background actions "via Hangfire" are removed — the platform
|
|
79
|
+
uses a hosted `BackgroundService` polling `background_action`, and `FileCleanupService`
|
|
80
|
+
says so in as many words.
|
|
81
|
+
|
|
82
|
+
### Changed — validation checklist
|
|
83
|
+
|
|
84
|
+
Two new items under actions: that a `canExecute` meant to *gate* is written in the
|
|
85
|
+
server-enforced subset, and that no bare boolean field is used as a whole condition. The
|
|
86
|
+
`references/formulas.md` entry for `canExecute:` blocks now summarises the same rules and
|
|
87
|
+
points at `action-dsl.md`.
|
|
88
|
+
|
|
89
|
+
## 0.2.14
|
|
8
90
|
|
|
9
91
|
Record reports — a report attached to an entity so it opens **from a record**, with the
|
|
10
92
|
record's values feeding its parameters — plus a report-level home for report parameters,
|
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.15",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes 34 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",
|
|
@@ -14,7 +14,8 @@ params:
|
|
|
14
14
|
|
|
15
15
|
canExecute:
|
|
16
16
|
# Single-expression formula returning boolean
|
|
17
|
-
#
|
|
17
|
+
# Enables/disables the button AND is re-checked server-side on execute
|
|
18
|
+
# (see "canExecute is enforced, with a fail-open subset")
|
|
18
19
|
|
|
19
20
|
onBeforeStart:
|
|
20
21
|
# Pre-queue initialization (async / background actions only)
|
|
@@ -396,6 +397,62 @@ For "who did this", prefer the platform audit columns (`created_by` /
|
|
|
396
397
|
predicates in `canExecute:` or a formula column, use `CURRENT_USER_ID()`, which
|
|
397
398
|
the server translates to an exact `::bigint` SQL literal.
|
|
398
399
|
|
|
400
|
+
> **The table above is about `execute:` (JavaScript).** The *formula* engine —
|
|
401
|
+
> `canExecute:`, formula columns, filters, reports — keeps large integer literals
|
|
402
|
+
> exact: the parser holds them as 64-bit integers and serializes anything past
|
|
403
|
+
> 2^53 as text so the browser cannot round it. `canExecute: [owner_id] = 215117312238288897`
|
|
404
|
+
> compares against the id you wrote. `CURRENT_USER_ID()` is still the better way to
|
|
405
|
+
> say "mine" — it does not hard-code an id — but a literal is no longer a
|
|
406
|
+
> correctness trap on that side.
|
|
407
|
+
|
|
408
|
+
## canExecute is enforced, with a fail-open subset
|
|
409
|
+
|
|
410
|
+
`canExecute:` is evaluated **twice**: by the browser to decide whether the button
|
|
411
|
+
is **enabled** (the action stays in the toolbar, greyed out — it is not hidden),
|
|
412
|
+
and by `action.execute` over every targeted record before any write. A
|
|
413
|
+
record in the wrong state is refused with `ACTION_EXECUTION_FAIL`, so the predicate
|
|
414
|
+
is a gate rather than a UI hint, and `canExecute: false` is the way to make an
|
|
415
|
+
action fireable only by a trigger or a scheduled job (both invoke the script engine
|
|
416
|
+
directly and bypass the check).
|
|
417
|
+
|
|
418
|
+
**Both evaluators have gaps, and both fail open**, but not the same gaps. The
|
|
419
|
+
server is a scalar walker and lets anything richer through. The client implements
|
|
420
|
+
the whole documented formula function set, so ordinary functions evaluate there
|
|
421
|
+
normally; it throws — and its `catch` **enables** the button — on ref navigation,
|
|
422
|
+
on the set aggregates `SUM` / `COUNT` / `AVG` (deliberately not formula functions;
|
|
423
|
+
counting belongs in a Generated `G` column), and on any name it does not know, a
|
|
424
|
+
typo included — a `canExecute:` is never validated against the function set at
|
|
425
|
+
install, so `TOADY()` ships and enables the button for every record.
|
|
426
|
+
`$[setting]` references are a separate case again: they do not throw, the client
|
|
427
|
+
resolver is stubbed to `null`, and the server has no settings resolver — so a
|
|
428
|
+
settings-dependent guard is unenforced server-side and resolves unpredictably in
|
|
429
|
+
the browser. Read settings in `execute:` instead.
|
|
430
|
+
|
|
431
|
+
| tier | construct | client | server |
|
|
432
|
+
|---|---|---|---|
|
|
433
|
+
| 1 | **comparisons** (`=` `==` `!=` `<>` `>` `>=` `<` `<=`) between `[field]`s and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
|
|
434
|
+
| 2 | arithmetic, `IN`, `BETWEEN`, any formula function (`TODAY`, `IF`, `DATEADD`, `REGEX_MATCH`, `CURRENT_USER_ID`, …) | evaluates | fail-open |
|
|
435
|
+
| 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
|
|
436
|
+
| — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
|
|
437
|
+
|
|
438
|
+
**A bare boolean column is not tier 1.** The walker evaluates conditions, not
|
|
439
|
+
values — a `[field]` resolves only as an operand of a comparison — so
|
|
440
|
+
`canExecute: [is_active]` and `canExecute: NOT [is_blocked]` fail open. Write
|
|
441
|
+
`[is_active] = true` and `NOT ([is_blocked] = true)`. A bare `false` is fine: it is
|
|
442
|
+
a complete condition, which is what makes the `canExecute: false` idiom work.
|
|
443
|
+
|
|
444
|
+
Tier 1 + tier 2 is the normal shape and behaves correctly: the client evaluates the
|
|
445
|
+
whole predicate so the button state is right, and the server short-circuits `AND` /
|
|
446
|
+
`OR` so the tier-1 half is still enforced.
|
|
447
|
+
|
|
448
|
+
**Tier 3 is what breaks.** The server short-circuits, the client does not, so
|
|
449
|
+
`[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record still
|
|
450
|
+
evaluates `COUNT` in the browser, throws, and the button is **enabled** — then the
|
|
451
|
+
server refuses the call. A `canExecute:` is not validated against the function set
|
|
452
|
+
at install, so that spelling ships silently. Count with a `G` column and compare the
|
|
453
|
+
scalar; denormalise instead of navigating; put the rest in `execute:` behind
|
|
454
|
+
`error()`.
|
|
455
|
+
|
|
399
456
|
### Messaging & notifications
|
|
400
457
|
|
|
401
458
|
#### `info(message, opts?)`, `warn(message, opts?)`
|
|
@@ -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
|
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
Formulas are used in:
|
|
4
4
|
|
|
5
5
|
- **Formula columns** (`columnType: "F"`) — computed values
|
|
6
|
-
- **Action `canExecute:` blocks** — availability checks
|
|
6
|
+
- **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
|
|
7
|
+
and **enforced** by `action.execute`, but only over a narrow subset: comparisons
|
|
8
|
+
between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
|
|
9
|
+
arithmetic, ref navigation, `$[setting]` reads and a bare boolean field all fail
|
|
10
|
+
open server-side, and some of them throw client-side. See the `canExecute:`
|
|
11
|
+
section in [action-dsl.md](action-dsl.md).
|
|
7
12
|
- **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
13
|
- **Filter expressions** (partially)
|
|
9
14
|
- **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:
|
|
@@ -119,6 +119,16 @@ For each action:
|
|
|
119
119
|
- [ ] 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
120
|
- [ ] DSL file has `params:`, `canExecute:`, and `execute:` blocks
|
|
121
121
|
- [ ] `canExecute:` is a valid formula expression
|
|
122
|
+
- [ ] If `canExecute:` is meant to **gate** and not just grey out a button, its
|
|
123
|
+
guard is expressed in the server-enforced subset — **comparisons** between
|
|
124
|
+
fields and literals, combined with `AND` / `OR` / `NOT`. Function calls
|
|
125
|
+
(`TODAY()`, `COUNT()`, `CURRENT_USER_ID()`), arithmetic, ref navigation and
|
|
126
|
+
`$[setting]` reads are fail-open server-side and are advisory only
|
|
127
|
+
- [ ] No **bare boolean field** as a whole condition — `[is_active]` and
|
|
128
|
+
`NOT [is_blocked]` are not enforced (a field is only an operand). Write
|
|
129
|
+
`[is_active] = true` / `NOT ([is_blocked] = true)`
|
|
130
|
+
- [ ] Anything that MUST hold is also checked in `execute:` with `error()` — a
|
|
131
|
+
`canExecute` alone is never the authoritative guard
|
|
122
132
|
- [ ] `execute:` uses only documented built-in functions
|
|
123
133
|
- [ ] Referenced entities exist
|
|
124
134
|
- [ ] Referenced parameters are declared in `params:`
|