From dbd71c315d84449f008caf3cf5a057c027835ebf Mon Sep 17 00:00:00 2001 From: delchev Date: Sun, 6 Sep 2026 19:30:47 +0300 Subject: [PATCH] intent: a create-from is guarded by the source's status (#7068) A `generates:` was unconditional. With a `sourceStatus:` completion hook it flipped the source once the target existed - and then went on offering the same button on the flipped record and answering the same endpoint 200, so a second click minted a second document: a proforma already INVOICED produced a second invoice, in the customer's hands. The hook DECLARED what "already done" looks like; nothing consulted it, and both halves of the model read correctly, which is why the gap survived review. One rule, resolved once (`GeneratesGuardSupport`), feeds both halves of the action: the generated `run()` refuses with 409 before anything is created, and the contributed action descriptor carries the same guard so the shared `customActions` store stops OFFERING the click on a record it would refuse (`getActions(view, type, record)` takes the record the four entity-action views already have). Two shapes: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently only because `from:` on a create-from already names the source ENTITY - and absent it a declared `sourceStatus` implies the deny-list of exactly that status, so a model carrying the defect today is corrected with no authoring change. The guard is on the CLICK: an event-driven create-from keeps its own at-most-once back-reference guard and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is refused at parse rather than silently ignored - as are a `page` scope, a source with no `function: EntityStatus` relation, and an allow-list containing the `sourceStatus` the action itself writes. The statuses are symbolic like every other status site. Emission is gated on a new `hasStatusGuard` key, so a `.glue` written before it renders the unguarded `run()` it always did. --- .claude/docs/intent-layer.md | 2 + components/engine/engine-intent/CLAUDE.md | 2 + components/engine/engine-intent/README.md | 11 ++ .../generator/GeneratesGuardSupport.java | 157 ++++++++++++++++++ .../intent/generator/GlueIntentGenerator.java | 12 ++ .../generates/GeneratesIntentGenerator.java | 26 ++- .../intent/model/GeneratesIntent.java | 34 ++++ .../intent/parser/IntentParser.java | 57 +++++++ .../intent/parser/StatusSymbolResolver.java | 9 +- .../main/resources/intent-assistant-guide.md | 4 + .../intent/generator/GlueGeneratesTest.java | 62 +++++++ .../intent/parser/GeneratesIntentTest.java | 63 +++++++ .../template/service/model/GlueGenerator.java | 7 +- .../shell/js/stores/customActions.js | 33 +++- .../events/Generate.java.template | 26 ++- .../document/document-view.html.template | 2 +- .../ui/perspective/list/view.html.template | 4 +- .../manage/list-view.html.template | 4 +- .../master/master-view.html.template | 4 +- .../integration/tests/api/IntentEngineIT.java | 16 ++ 20 files changed, 516 insertions(+), 19 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GeneratesGuardSupport.java diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index d181f6012c8..424232d3278 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -28,6 +28,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **The enrichment channel (`phases:` + `onPhase`, [#6929](https://github.com/eclipse-dirigible/dirigible/issues/6929)):** a value a listener computes AFTER the insert — a moving-average cost, a snapshot column, an external lookup — must be written back **event-silently** or it re-fires every onUpdate consumer of a change the user never made; so it published nothing at all, and a declarative consumer of that value had no moment to bind. Bound to `onCreate` it RACED the enrichment (two listeners on one topic have no order — each `MessageHandler` is its own durable subscriber, and there is no priority anywhere), and posted a balanced-looking journal entry for a null amount with parse, generation, compile and publish all green. The fix is a CHANNEL, not an ordering contract the broker cannot keep: an entity declares the moments it announces (`phases: [costed]`), the Java DAO template emits one **`announce(id, values)`** per phase — `updateProperties` with the phase's own topic, so the enrichment and its notice ride ONE write into the outbox and commit together — and any glue consumer binds `event: { onPhase: , phase: }`. The generated method is the point: a hand-typed topic string reproduces exactly the silence being removed, a mistyped `announceCosted` is a compile error. Accepted by `postings:` (the driver), `notifications:`, `integrations:`, `outbound:` and an event-driven `generates:`, with the `when:` guard optional there (the phase already IS one moment); deliberately not by a process `trigger:`, a `wait` or `resolves:`. Refused at parse, each because it is otherwise silent: a phase that is not a lower-camel identifier, one named after a platform channel (`updated`/`deleted`/`transitioned`/`rekeyed`), a duplicate, a `phase:` key on another axis, and a binding naming a phase the entity does not declare. Details in the engine-intent guide's phases bullet. +**A create-from is not offered twice (`fromStatus:`, [#7068](https://github.com/eclipse-dirigible/dirigible/issues/7068)):** a `generates:` with a `sourceStatus:` completion hook flipped its source once the target existed and then went on offering the same button on the flipped record - and answering the same endpoint 200 - so a second click minted a **second document**: a proforma already INVOICED produced a second invoice, in the customer's hands. The hook declared what "already done" looks like; nothing consulted it. A create-from now carries a from-status guard resolved ONCE and fed to both halves of the action: the generated `run()` refuses with **409** before anything is created, and the contributed action descriptor carries the same guard so the shared `customActions` store stops OFFERING the click on a record it would refuse (`getActions(view, type, record)` takes the record the view already has). Two shapes: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently only because `from:` on a create-from already names the source ENTITY - and absent it a declared `sourceStatus` IMPLIES the deny-list of exactly that status, so a model that already carries the defect is fixed with no authoring change. The guard is on the CLICK: an event-driven create-from keeps its own at-most-once back-reference guard and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is refused at parse rather than silently ignored - as are a `page` scope, a source with no `function: EntityStatus` relation, and an allow-list containing the `sourceStatus` the action itself writes. + **Event-driven create-from (`generates` + `event:`, [#6711](https://github.com/eclipse-dirigible/dirigible/issues/6711)):** a `generates` entry may declare `event: { onTransition: , when: " == " }` (guard mandatory) or `{ onCreate: }` and mint the follow-up **document — header AND items** by itself when the source reaches a state, instead of waiting for the button (`posts` is event-driven but emits flat rows and cannot reference the new header). The `map` entry copying the source's key IS the back-reference and therefore the **at-most-once** guard, derived rather than declared twice; the button is dropped unless `button: true`, and both triggers share ONE generated create-from (a new `GenerateOnEvent.java.template` listener calls `Generate.create(id)` and carries no mapping of its own). Details in the engine-intent guide's `event:` bullet. **Declarative step resilience (`retry:` / `onError:` / `{error}` / `vars:`, [#6762](https://github.com/eclipse-dirigible/dirigible/issues/6762)):** a `delegate:` service task may declare `retry: { count, every }` (a Flowable failed-job retry cycle, `R/` — the R number counts TOTAL attempts) and `onError: ` (an error boundary event routed like a decision branch). The boundary only fires on a `BpmnError`, so `engine-bpm-flowable`'s `ResilientClassDelegateFactory` converts the FINAL failed attempt (mirroring `JobRetryCmd`'s arithmetic) into the caught `INTENT_STEP_FAILED` error, publishing the failure message as the `__errorMessage` variable — which a `setField` step on the error route reads via the whole-value `{error}` token. Process-level `vars:` + step `produces:`/`uses:` declare step data (an undeclared name is a parse error) and `clearAfter: ` removes a value — a generated credential — from the instance data once that step completes. v1 is delegate-only (parser-enforced); absent keys generate byte-identically. Details in the engine-intent guide's step-resilience bullet. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index f5908294ef4..9e70be8a420 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -446,6 +446,8 @@ Semantics worth knowing: - **`phases:` (entity) + `onPhase` (the glue event axis) = the enrichment channel (#6929).** An enrichment a listener computes on create - a moving-average cost pool, a snapshot column, an external lookup - must be written back WITHOUT an event or it re-fires every onUpdate consumer of a change the user never made; so it publishes nothing at all, and a declarative consumer of the enriched value had **no moment to bind**. Bound to `onCreate` it raced the listener - the order of two listeners on one topic is undefined by construction (`ListenerClassConsumer` gives each subscription its own durable subscriber; there is no priority anywhere) - and read the un-enriched row: a balanced-looking journal entry posted for a null amount, with the parse, the generation, the compile and the publish all green. The **silently-wrong** class, which is why neither "document the race" nor a global ordering tier was the answer: the first leaves the automation unexpressible and the second is a contract the messaging layer cannot keep. What was missing was a CHANNEL, and the platform already had the primitive for one - `updateProperties(id, values, topic)`, whose event is recorded in the tenant's outbox inside the write's own transaction (the same mechanism `transitions:` and `sourceStatusOnRetire:` ride). So: an entity declares the moments it announces (`phases: [costed]`), `EdmIntentGenerator` carries them onto the `.model` entity map as a comma-joined `phases` (the `lifecycleEdges` convention), and the Java DAO template emits one **`announce(id, values)`** per declared phase - `updateProperties` with the phase's own topic, so the values and the notice commit together and no consumer can observe one without the other. The method is the point: a hand-typed topic string would reproduce exactly the silence the axis removes, whereas a mistyped `announceCosted` is a compile error the Problems view shows. An **empty values map throws** rather than no-opping - the write IS the announcement, and a phase nothing wrote is a moment that did not happen. The read half is one new kind on the shared axis: `event: { onPhase: , phase: }`, entity-in-the-kind + a sibling key exactly like `model:`, so `EventBinding.entity()` and the cross-model resolution are untouched. `EventBinding` gains `ON_PHASE`/`phase()`/**`topicSuffix(Map)`** - the suffix is DATA here, not a constant of the kind, so the kind-only `topicSuffix(String)` **throws** for `onPhase` rather than answering `""` (silently binding the un-enriched moment is the failure this exists to remove). Accepted by `postings:` (the driver), `notifications:`, `integrations:`, `outbound:` and an event-driven `generates:`; the `when:` guard is optional on it, the phase already being one moment, which is also why `validatePostings` had to stop treating "not a create" as "requires a status guard". Deliberately NOT widened to a process `trigger:` or a `wait` (their own closed key sets reject it loudly) nor to `resolves:`, whose vocabulary was already narrower - the same line `onTransition` drew. `Posting.java.template` was the one template still hardcoding its channel (`#if(!$isCreate)-transitioned#end`); it now renders `${topicSuffix}` with an `isCreate` **fallback**, so a `.glue` written before the axis existed keeps binding exactly what it did (the `$eventOnly` precedent). Parse-time refusals, each because it is otherwise silent: a phase that is not a lower-camel identifier (it becomes a method name and a topic), a phase named after a platform channel (`updated`/`deleted`/`transitioned`/`rekeyed` - announcing it would re-fire that channel's consumers), a duplicate, a `phase:` key on any other axis (dropped, and the consumer keeps racing), and a binding naming a phase the entity does not declare (a topic nothing publishes to). A cross-model source's phases live in its own model and cannot be checked from here - the cross-model status nomenclature has the same limit. Covered by `EntityPhaseIntentTest` + `GluePhaseAxisTest` + `EdmIntentGeneratorTest` + `IntentEngineIT.a_declared_phase_gives_an_enrichment_its_own_channel_a_posting_can_bind`. - **`transitions:` (top-level) = guarded on-demand status flip (void / cancel / close / reopen).** The missing affordance for a document whose create-time process has ENDED: process triggers fire only on create/update/delete, and `actions:` only opens a custom page - nothing declarative could transition a finished document again. `TransitionIntent` + parser `validateTransitions` (forEntity must declare a `function: EntityStatus` relation; `from:` = non-empty list of allowed source seed ids; `setStatus:` = target seed id not in `from`; optional `when: " ==|!= "` guard over an own field, resolved case-insensitively - the identifier follows the Calc PascalCase convention). Two halves, the `generates` pattern: `TransitionsIntentGenerator` (`@Order(470)`) contributes the per-record button (`-transition-action.extension`/`.js` on `-custom-action`, descriptor carries `endpoint`); `GlueIntentGenerator.buildTransitions` pre-renders EVERYTHING (the `allowedExpr` over an `int currentStatus` local, the `when` guard as a full `Calc.eval(...).compareTo(...)` expression - null field reads as 0) into the `transitions` glue collection -> the pipeline's collection case -> `Transition.java.template`: a `@Controller` at `gen/events//Transition/run` that re-loads the record, returns **409** (via `sdk.http.Response.setStatus`) with the reason when a guard fails, flips ONLY the status column via the targeted `updateProperty` (no `-updated` re-fire - no onUpdate reactions), re-loads, and publishes `-transitioned` - the SAME channel the workflow setters and `generates.sourceStatus` publish, so `postings:` glue observes a manual void exactly like a workflow transition. This realizes the "guarded transition" half of the Tier-2 `lifecycle:` sketch below for the post-process case. Covered by `TransitionsIntentTest` + `GlueTransitionsTest` + the `IntentEmissionCoverageIT` transitions assertions. - **`generates` + `event:` = the create-from runs itself (#6711).** A create-from was strictly a **user action** - a button on the source view - so "when the source reaches this state, mint the follow-up document" had no expression: a `generates` button plus a process `wait` degraded the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven but emits **flat mapped rows** and cannot reference the freshly created header, and the remaining option was a hand-written `delegate`. A `generates` entry now accepts `event: { onTransition: , when: " == " }` (guard mandatory, status by seeded NAME or id) or `{ onCreate: }` (guard optional - a source with no lifecycle), mirroring `postings`' event axis. **The event says WHEN, never what**: the entity it names must be the one `from:` declares and `model:` is rejected (`fromUses:` owns that), both parser-checked - two ways to name the source could only drift. **At-most-once is derived, not declared twice**: the `map` entry copying the source's PK IS the back-reference, so `GlueIntentGenerator.putGeneratesEvent` derives `backRefProperty` from it and fails loudly when it is missing (the parser catches the local case earlier with the fix in the message; the cross-model source's key field is only known once the owner `.model` resolves). Emission: the existing `Generate.java.template` was refactored so its body is a `create(Integer sourceId)` method carrying the guard (`findAll(eq(backRef, sourceId))` -> return the existing document), and a new **`GenerateOnEvent.java.template`** renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-loads the source, applies the status guard and calls `new Generate().create(id)` - **it carries no mapping of its own**, which is what keeps the two triggers from diverging. The listener is a collection of its own (`generateEvents`, the filtered `generates` list - one file per entry is the collection contract, and a create-from with no event must contribute no listener) but shares `bindGenerate`, so both templates see the same descriptor. `button:` decides the click half: default **true** without an event and **false** with one (declaring an event is how an author says nobody has to click), `button: true` keeps both (they share the one guard), `button: false` with no event is rejected - the action would have no trigger at all. Without a button the class gets no `@Controller`/`@Post` and no custom-action descriptor or i18n label - no endpoint nothing links to. **The template gates the controller half on the NEGATIVE (`#if(!$eventOnly)`)** so a `.glue` written before this key existed keeps rendering the endpoint it always did. `sourceStatus:` composes (the flip cannot re-trigger the create-from - the guard has already claimed the source), and **the flip runs BEFORE the target is saved** while the `-transitioned` publish stays after it: the flip is a lifecycle move the source's repository enforces, so a move the graph does not declare must throw with nothing yet created. Flipping afterwards was the worst possible order - a committed document whose source never transitioned, so every posting and integration keyed on the new status silently never ran, and the back-reference guard then made a redelivery return that document instead of repairing the flip. Now a redelivery re-runs the flip as a no-op (`previous == next`) and goes on to create what is missing. The parser closes the authoring half in the same pass: `validateStatusWritesAgainstLifecycle` covers `generates[].sourceStatus` and every `resolves:` outcome `setStatus` alongside the workflow setters and checks it already covered. (`sourceStatus` - and its inverse `sourceStatusOnRetire` - are now among the sites `StatusSymbolResolver` rewrites, so both take a seeded NAME or an id; they were id-only until #6868, and leaving the pair asymmetric would have been a wart of its own.) Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference) + `IntentEmissionCoverageIT` at both layers: posting a Slip mints the Voucher **with its computed line** while nobody calls the create-from, and a click afterwards returns that same voucher. **The guard asks state, not existence (#6814).** It first shipped as `findAll(eq(backRef, sourceId))` — pure existence — and a voided target answers that forever: it keeps existing and keeps back-referencing the source, so the source's one-shot slot was consumed at the first creation and nothing that later happened to the target released it. "Void and reissue", an ordinary business flow, was inexpressible. The state half reuses the **`stage:` classification** the report `scope:` already resolves through (`LifecycleStages`), never a second key on the create-from — two vocabularies for "this row no longer counts" could only drift: `putSupersededTarget` reads the LOCAL target's `function: EntityStatus` nomenclature, collects the `cancelled` + `void` seed ids and pre-renders `hasRetiredStatus` / `retiredStatusProperty` / `retiredStatusCondition`, and the template turns the `if (!existing.isEmpty())` into a loop that steps over a retired candidate. Draft and live targets still block, so redelivery idempotence is untouched; the voided document is KEPT (both stay on the audit trail) rather than replaced in place. A target with no lifecycle keeps the existence-only guard silently (nothing can retire it); one that HAS a lifecycle whose nomenclature nobody classified keeps it with a **generation warning** — that is the silent combination, where the guard looks state-aware and is not. A cross-model target's seeds live in its owner model, so no classification is resolvable here (the report scope has the same limit). `mode: append` (#6800) is NOT this: it is the ABSENCE of a guard, so every qualifying event mints another document. **And the guard belongs to ONE rule (`validateIdempotencyGuardOwnership`).** It asks whether the source already has a row through the back-reference and cannot tell which rule wrote it, so two event-driven `generates:`/`posts:` rules sharing a target AND a back-reference silently divide into a winner and a loser - the first to fire claims the source forever, the other returns that row instead of writing, for that source and every future one. It parsed, generated and compiled, and the loser read as a rule whose condition never matched; disjoint `when:` guards do not help, because existence decides it, not the condition. Both halves of the key are static in the model, so it is a parse error now. Two `mode: append` rules are exempt (neither reads the other's rows - the exemption #6800 created), but append PLUS guarded is not: the appended rows carry the back-reference, which is all the guarded rule's lookup needs to be satisfied forever. The check spans both constructs, since a `posts:` row satisfies a `generates:` guard just as well. Covered by `CollidingGuardIntentTest`. **And a freed slot needs something able to refill it: `sourceStatusOnRetire:` (#6868).** #6850 unlocked the door and, for the `sourceStatus:` combination, left nobody able to knock: the completion hook flips the source OFF the status its own `event.when` qualifies on - deliberately, so the guard-claimed source stops matching - and the usual lifecycle graph declares no edge back, so the retired target frees the slot and no qualifying `-transitioned` is ever published again. An event-only rule (`button: false`, the shape #6711 introduced the axis for) had NO reissue path at all. The fix is the hook's **declared inverse** on the same rule, so the reissue is the ORDINARY path rather than a special one: `GenerateReopen.java.template` (collection `generateReopens`, the same filtered descriptors and the same `bindGenerate`) renders a `MessageHandler` on the TARGET's `-transitioned` topic that re-loads the target, tests the SAME retiring-`stage:` set the guard uses (`putSupersededTarget` pre-renders the disjunction twice, once per local - one resolution, so guard and reopen cannot disagree about what retired means), reads the source through the same `backRefProperty`, and flips it with `updateProperties(id, {status: reopen}, "-transitioned")` - the notice riding the write into the outbox, as `transitions:` does, so flip and announcement commit together and `GenerateOnEvent` cannot miss the moment that frees it. Then the trigger re-fires, the guard steps over the retired document, and the replacement is minted by machinery that already existed. **Idempotence is by STATE, not a marker column**: it acts only while the source still stands at this rule's own `sourceStatus` AND no target of that source still counts - the create-from's guard asked from this end, over the same classification, which is the half that actually closes redelivery. Delivery is at-least-once, so a redelivered void arrives AFTER the replacement exists; the standing-status test alone passes there (the reissue put the source back at `sourceStatus`) and would re-open a source with a live target against it - `create()` then returns at its own guard, so nothing would ever put the status back. The free-slot scan makes the reopen run exactly when a creation would be allowed through. The two directions the issue also weighed were rejected: re-delivering the source's qualifying event from the target's retirement fabricates a transition that did not happen (and, since the source stands at the POST status, would not even match the guard without bypassing it) and re-fires every other consumer of that topic; documenting-and-warning leaves the automation unexpressible. `validateGeneratesReopen` refuses every combination that could never fire - no `sourceStatus:`, the same status, `mode: append` (no guard, no slot), **no `event:`** (the emission is gated on event-driven, so a button-only reopen would be authored and silently dropped - and there the button IS the reissue), a cross-model target (its stages are classified in the owner model), a target with no lifecycle, an unclassified nomenclature - and `validateStatusWritesAgainstLifecycle` pins the source's graph to the ONE edge `sourceStatus` -> reopen, the exact place the source stands when the retirement arrives (it now takes `edges` as well as `reachable` for that). Covered by `GeneratesIntentTest` (nine cases, all of which parse silently without the validator), `GlueGeneratesTest` (the emitted inverse, and byte-identical output without the key) and `IntentEngineIT.generates_reopen_returns_the_source_when_its_target_is_retired`. Deliberately NOT added to `IntentEmissionCoverageIT`'s `voucher-from-slip`: its #6814 test reissues by POSTing the endpoint, and an automatic reissue racing that POST could mint a third voucher and make a green test flaky. + +- **`fromStatus:` (and the guard `sourceStatus:` implies) = a create-from is not offered twice (#7068).** A `generates:` was unconditional. With a `sourceStatus:` completion hook it flipped the source once the target existed - and then went on offering the same button on the flipped record and answering the same endpoint 200, so a second click minted a **second document**: a ProformaInvoice already INVOICED produced SalesInvoice 10 and then SalesInvoice 11, both in the customer's hands. The hook DECLARED what "already done" looks like; nothing consulted it - the authored-but-unconsumed class, and the reason the gap was invisible (both halves of the model read correctly). The fix is one rule resolved once, `GeneratesGuardSupport`, feeding **both** halves of the action: the generated `run()` refuses with **409** before anything is created (the pre-check load is on the guarded path only), and the contributed action descriptor carries the same `guard: { property, allowed | blocked }` so the shared `customActions` store stops OFFERING the click on a record it would refuse - the store's `getActions(view, type, record)` gained an optional record and the four entity-action sites pass the one they already have (`selected`, and the document form). Two shapes, one guard: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently ONLY because `from:` on a create-from already names the source ENTITY, which is why the issue's suggested `from:` could not be taken literally; absent it, a declared `sourceStatus` IMPLIES the deny-list of exactly that status, which is the minimal refusal and needs no authoring change for the models that already carry the defect. The guard is on the **click**, deliberately: an event-driven create-from already carries the at-most-once back-reference guard (or asked for a row per event with `mode: append`) and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is **refused at parse** rather than silently ignored. Also refused: a `page` scope (no record to read a status from), a source with no `function: EntityStatus` relation (nothing to read), and an allow-list containing the `sourceStatus` the action itself writes (it re-opens exactly the duplicate the guard removes). The statuses are symbolic like every other status site (`StatusSymbolResolver.rewriteGenerates` now resolves `fromStatus` too). Emission is gated on a new `hasStatusGuard` boolean, so a `.glue` written before the key existed renders the unguarded `run()` it always did. Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `IntentEngineIT.generates_completion_hook_flips_the_source_via_targeted_update` (the 409 branch, its ordering before the create, and the descriptor's guard). - **A mutual cross-model `generates` cycle bootstraps through a declared pass, not a hand-strip ([#6539](https://github.com/eclipse-dirigible/dirigible/issues/6539)).** A cross-model create-from is resolved against the target's real `.model` (`CrossModelSupport.resolve`, workspace-or-registry, loud on absence), which has no first project when the pair is MUTUAL - the canonical opportunity -> quotation funnel, where A mints a document into B while B holds a foreign key back to A: A cannot generate because B's `.model` does not exist, and B cannot because A's does not. The workaround was to strip A's `generates` block, generate A, generate B, restore the block, regenerate A - five steps, four of them editing the intent to say something it does not mean. Now the pass itself takes `bootstrap=true` (`POST /services/ide/intent/generate?...&bootstrap=true` -> `IntentGenerationService.generate(..., bootstrap)` -> `IntentGenerationContext.isBootstrap()`), which skips exactly the create-from whose owner model is not there yet and names it in `warnings`: bootstrap here, generate the dependency, regenerate here normally. **Absence is the whole trigger, and it is asked as a separate question** - `CrossModelSupport.ownerModelExists` tests whether the owner's `.model` FILE is readable from either source, deliberately narrower than `resolve` succeeding, so an owner that IS there but declares no such entity keeps failing loudly in a bootstrap pass too ("the dependency is not generated yet" and "the reference is wrong" want opposite answers, and the second is the one a bootstrap flag could hide forever). **Nothing else is relaxed**: a cross-model RELATION never degrades to a guess - its table, key column and FK type would have to be invented and the emitted schema would be wrong rather than incomplete - and lazy resolution was rejected for the same reason (a `generates` glue entry needs the target's perspective + PK at glue-generation time, and the convention fallback is exactly the dead-dropdown guess `CrossModelSupport` exists to refuse). **The default pass teaches the escape**: `buildGenerates` asks the absence question in both modes and, outside a bootstrap, throws its own `BootstrapRequiredException extends IntentValidationException` naming the cycle and the three-step recipe - so the endpoint can answer the ordinary 422 plus `bootstrap: true`, the one fact a caller cannot read out of the text, and the Intent Editor offers "Generate anyway" as a retry instead of leaving the developer to edit the document. Covered by `GlueGeneratesBootstrapTest` (skip + warning, the loud default with the recipe, and the present-but-wrong reference staying fatal under bootstrap) and `IntentEngineIT.mutual_cross_model_generates_bootstraps`. - **`generates.event:` on the process-step axis + an opt-in `mode: append` (#6800).** Two narrow extensions that together close "on event E, append a derived row" - a `LogEntry` per process step, a protocol line per transition - which **no** event-driven construct could express: every candidate either writes into an existing row (`postings`/`rollups`/`aggregates`), or was at-most-once by construction (`generates` + `event:`), so the shape needed a hand-written listener under `custom/` or an `outbound` -> `inbound` loopback. (1) The `event:` map now also takes the **step axis** `onStepReached`/`onStepCompleted: { process, step }` that `notifications`/`integrations`/`outbound` already bind to (#6537) - so a create-from can hang off a moment in a flow rather than a status write, which is also the one route around a state write that publishes nothing. Its extra narrowing over the other consumers: the process's `trigger:` entity must EQUAL `from:` (the step event is delivered as a message about the process's trigger record, and that record is what the create-from reads by id), and the source must be local - a process and its steps belong to the model that declares them, so a `fromUses:` source is rejected. `when:` stays optional on this axis: the step IS the moment. (2) `mode: once` (**default** - unchanged behaviour, byte-identical output) vs `mode: append`, which drops the existing-target lookup in `Generate.java.template` (`#if($hasEvent && !$appendMode)`, the single guard site, inside the shared `create()`), so every delivery creates a row. **The back-reference stays REQUIRED in both modes** - the dedup key under `once`, the row's provenance under `append` (a log row nothing points back at cannot be read); the parser message names both roles. Emission: `putGeneratesEvent` gained `isStep`/`stepProcess`/`stepName`/`topicSuffix`/`appendMode`, and the listener's `destination()` now renders `${topicSuffix}` instead of branching on `isCreate` (`""` for a create, `-transitioned` for a transition, `-step---reached|completed` for a step - same strings as before). **`StepEventSupport.boundEvents` had to learn about `generates`**, not just `GlueIntentGenerator`: `emitters()` reads that list, so without it a moment whose ONLY consumer is a create-from got no `JavaDelegate` emitter and the listener bound a topic nothing published to. **What `append` is NOT:** a state-aware guard. It is the ABSENCE of one - a redelivery appends a duplicate (the step topic is published after commit, not transactionally with the step, the same at-least-once contract `outbound` states), and it is the wrong answer to "I voided the target and cannot regenerate it" (that is #6814's stage-aware predicate on `mode: once`). Two `append` rules sharing a target AND a back-reference are **legal by design** (each records a different moment) - which is why #6813's parse-time collision diagnostic must be scoped to `once` pairs only. Covered by `GeneratesIntentTest` (step binding accepted; unknown process/step, non-eventable kind, trigger-entity mismatch, cross-model source, a mode with no trigger, an unknown mode, a missing back-reference under append, a prompt on an appending create-from all rejected) + `GlueGeneratesTest` (the step topic, `appendMode`, the emitter for a generates-only moment, and both lifecycle axes unchanged) + `IntentEmissionCoverageIT.assertGeneratesStepAxisRuntime` - one shipment whose all-serviceTask flow appends TWO log rows from two moments sharing the same back-reference, a click appending a THIRD, and an at-most-once sibling on the same moment minting exactly one summary that a later click hands back. - **`prompt:` on a `generates` action = a declared input form before the create (#6685).** The gap it closes: `transitions:` writes but takes no input and `generates:` creates but declares every value up front, so an action that collects the two answers the source cannot derive (which payment, how much) had to be a hand-written page. It reaches a post-issue child on an IMMUTABLE document too, because per-record action buttons are deliberately NOT gated on mutability (that is why Void works) - the **action-shaped sibling of `locksWithMaster: false`** (#6700), which reopens the child's own panel: the panel is the affordance for ordinary data entry, a prompted action for a guided create over mostly-derived values. `prompt:` entries name fields / to-one relations of the TARGET; parser (`validateGeneratesPrompt`): local target only, target must declare a composition to-one relation to `forEntity` (that guarantees the generated detail registration the dialog renders from), scope `entity`, no `timestamp` fields, no overlap with `map`/`defaults` (one writer), no duplicates, and **no `event:`** (an event-driven create-from runs with nobody there to answer the form - which is also why the prompted values ride the ENDPOINT path only: `run()` checks the required ones and passes the map into `create(sourceId, values)`, while the event listener's `create(sourceId)` signature is untouched). Server half: `promptFields` in the glue (PascalCase prop + required + a pre-rendered `Object raw` -> field-type conversion), `Generate.java.template` takes `values` in the Request, 400s on a missing required input BEFORE anything is written, and sets prompted values after map/defaults - the save still goes through the target's repository so numbering/checks/events fire. Client half: the descriptor carries `prompt` + `promptEntity` (authored names ONLY - control types, lookup URLs and `dependsOn` metadata are resolved AT RUNTIME from `App.detailsFor(view)`'s edit-columns registration, so the intent layer never references template routes); the shared `customActions` store opens an input dialog instead of the plain confirm (`openPrompt`/`promptRun` + a mini dependsOn cascade seeded from the clicked master id - the invoice's Customer chain narrows the payment list, `valueFrom` defaults the amount), degrading to the confirm when the registration is absent (the shared shell). Dialog markup rides in all five shells wrapped in the `customActionPrompt` Alpine component so the Velocity shell stays `$store`-free. Covered by the `GeneratesIntentTest` prompt tests + `GlueGeneratesTest.promptFieldsRenderTypedConversions` + the `IntentEmissionCoverageIT` prompted-generates assertions (emission + 400 + value-reaches-the-row). diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 46e60f03f8b..da9858ab703 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -493,6 +493,7 @@ generates: map: { Customer: Customer } defaults: { InvoiceDate: now } items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } } + fromStatus: [2] # optional guard: the SOURCE statuses the action may run from sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus after creation sourceStatusOnRetire: 2 # optional INVERSE: where the SOURCE returns when the target is retired ``` @@ -517,6 +518,16 @@ status init and calculated fields fire. `sourceStatus:` flips the SOURCE to the seed id once the target exists (proforma -> INVOICED) - a system write: no `-updated` re-fire, but the source's `-transitioned` topic is published. +`fromStatus:` (#7068) guards the CLICK: the endpoint answers **409** and the button stops offering +itself unless the source stands in one of the listed statuses (seeded names or ids) - the `from:` of a +`transitions:` entry, spelled differently only because `from:` here already names the source ENTITY. +Declaring `sourceStatus:` and no `fromStatus:` IMPLIES the guard against exactly that status: a source +already standing where the completion hook put it has been generated from, and a second click used to +mint a second document (another invoice for an already-invoiced proforma, in the customer's hands). It +is refused where it cannot mean anything - a `page` scope, a source with no `function: EntityStatus` +relation, an event-only create-from (guard the moment with `event.when:`), and an allow-list that +contains the `sourceStatus` the action itself writes. + `event: { onTransition: , when: "Status == " }` (or `onCreate`, or a process step) mints the target with nobody clicking; the `map:` entry copying the source's key is then the **at-most-once guard**, and a target retired into a `cancelled`/`void` `stage:` stops blocking, so the diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GeneratesGuardSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GeneratesGuardSupport.java new file mode 100644 index 00000000000..a31853ae7fc --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GeneratesGuardSupport.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import java.util.List; + +import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.GeneratesIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.components.intent.model.UsesIntent; + +/** + * The from-status guard of a create-from (issue #7068) - the one rule, resolved once, for both + * halves of the action: the generated controller that refuses the run with 409, and the contributed + * button that stops offering it. + * + *

+ * A {@code generates} used to be unconditional. With a {@code sourceStatus:} completion hook it + * flipped the source once the target existed - and then went on offering the same button on the + * flipped record, so a second click (or a second POST) minted a second document: another invoice + * for a proforma already INVOICED, in the customer's hands. The hook declared what "already done" + * looks like; nothing consulted it. + * + *

+ * Two shapes, one guard. {@code fromStatus: [...]} is the explicit allow-list, the {@code from:} of + * a transition (spelled differently only because {@code from:} on a create-from already names the + * source ENTITY). Absent it, a declared {@code sourceStatus} IMPLIES the deny-list of exactly that + * status - the minimal refusal, and the one the author already declared: a source standing at its + * post-generation status has been generated from. + */ +public final class GeneratesGuardSupport { + + private GeneratesGuardSupport() {} + + /** + * A resolved guard over the SOURCE's {@code EntityStatus} foreign key: the record may run the + * create-from while its status is one of {@code allowed} (when an allow-list was authored) and + * while it is none of {@code blocked} (the implied one). Exactly one of the two is non-empty. + * + * @param statusProperty the source's status FK, PascalCase (e.g. {@code Status}) + * @param allowed the authored allow-list of status seed ids, empty when the guard is implied + * @param blocked the implied deny-list of status seed ids, empty when an allow-list was authored + */ + public record Guard(String statusProperty, List allowed, List blocked) { + + /** The guard as a Java boolean expression over an {@code int currentStatus} local. */ + public String expression() { + StringBuilder terms = new StringBuilder(); + for (Integer status : allowed.isEmpty() ? blocked : allowed) { + if (terms.length() > 0) { + terms.append(allowed.isEmpty() ? " && " : " || "); + } + terms.append("currentStatus ") + .append(allowed.isEmpty() ? "!= " : "== ") + .append(status); + } + return terms.toString(); + } + + /** The human half of the refusal, read after the action's name in the 409 body. */ + public String text(String fromEntity) { + return allowed.isEmpty() + ? "was already generated from this " + fromEntity + " (its status is the one the completion hook writes)" + : "is allowed only from status [" + join(allowed) + "]"; + } + + /** The status ids the guard names, for the javadoc of the generated controller. */ + public String statuses() { + return join(allowed.isEmpty() ? blocked : allowed); + } + + private static String join(List ids) { + StringBuilder out = new StringBuilder(); + for (Integer id : ids) { + if (out.length() > 0) { + out.append(", "); + } + out.append(id); + } + return out.toString(); + } + } + + /** + * The guard of a create-from, or {@code null} when it has none: nothing to check (no allow-list and + * no completion hook), no status column on the source to check it against, a {@code page}-scoped + * action, which acts on the view rather than on a record, or an event-driven one with no button at + * all - the guard is on the click, and an event trigger carries its own at-most-once guard. + * + * @param g the create-from + * @param statusProperty the source's status FK as the caller already resolved it (may be empty) + * @return the guard, or null + */ + public static Guard of(GeneratesIntent g, String statusProperty) { + if (statusProperty == null || statusProperty.isEmpty() || !"entity".equals(g.getScope()) || !g.hasButton()) { + return null; + } + if (g.hasFromStatus()) { + return new Guard(statusProperty, List.copyOf(g.getFromStatus()), List.of()); + } + return g.getSourceStatus() == null ? null : new Guard(statusProperty, List.of(), List.of(g.getSourceStatus())); + } + + /** + * The SOURCE's {@code function: EntityStatus} foreign key, PascalCase - read off this model for a + * local source, and off the owner's already-generated {@code .model} for a cross-model one (the + * status FK is author-named, so it is never guessed). Empty when the source declares none, or when + * the owner model is not resolvable here - the button then carries no guard and the generated + * controller's 409 stays the contract. + * + * @param g the create-from + * @param model the model being generated + * @param context the generation context (may be null outside a Generate) + * @return the status property, or an empty string + */ + public static String statusProperty(GeneratesIntent g, IntentModel model, IntentGenerationContext context) { + if (g.getFrom() == null || g.getFrom() + .isBlank()) { + return ""; + } + if (g.isCrossModelSource()) { + UsesIntent uses = null; + for (UsesIntent candidate : model.getUses()) { + if (g.getFromUses() + .equals(candidate.getModel())) { + uses = candidate; + } + } + if (uses == null || context == null) { + return ""; + } + CrossModelSupport.TargetInfo owner = CrossModelSupport.resolve(context, uses, g.getFrom()); + return owner == null || owner.statusProperty() == null ? "" : owner.statusProperty(); + } + for (EntityIntent entity : model.getEntities()) { + if (g.getFrom() + .equals(entity.getName())) { + for (RelationIntent relation : entity.getRelations()) { + if (relation.isEntityStatus()) { + return IntentNaming.pascalCase(relation.getName()); + } + } + } + } + return ""; + } + +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 617182ce17c..3e81a291a45 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -1017,6 +1017,18 @@ private static List> buildGenerates(IntentModel model, Map view = new LinkedHashMap<>(); view.put("id", project + "-" + g.getForEntity() + "-" + g.getName()); String label = IntentNaming.customActionLabel(g.getName(), g.getLabel()); @@ -141,6 +145,22 @@ private static String buildDescriptorModule(String project, String javaModule, G if (g.getOrder() != null) { view.put("order", g.getOrder()); } + if (guard != null) { + // The from-status guard (issue #7068): the button stops offering itself on a record the + // action would refuse - a proforma already INVOICED must not carry a live "Generate + // Invoice". The descriptor carries the source's status FK and the ids, so the shared + // customActions store decides it from the record it already has, with no extra request; + // the generated controller's 409 stays the contract for every other caller. + Map gate = new LinkedHashMap<>(); + gate.put("property", guard.statusProperty()); + if (!guard.allowed() + .isEmpty()) { + gate.put("allowed", guard.allowed()); + } else { + gate.put("blocked", guard.blocked()); + } + view.put("guard", gate); + } if (g.hasPrompt()) { // Declared input form (issue #6685): the customActions store opens a dialog instead of the // plain confirm, rendering one control per entry. The descriptor carries only the authored diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesIntent.java index 2cbcb1a7ab6..ff03a0e86b1 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/GeneratesIntent.java @@ -188,6 +188,27 @@ public class GeneratesIntent { */ private Integer sourceStatusOnRetire; + /** + * Optional guard on the SOURCE's own status (issue #7068): the create-from is refused (409) when + * the source record does not currently stand in one of these {@code EntityStatus} seed ids - the + * {@code from:} of a {@link TransitionIntent}, spelled {@code fromStatus} here because + * {@link #from} already names the source ENTITY. + * + *

+ * When it is absent but {@link #sourceStatus} is declared, the guard is IMPLIED and refuses the one + * state that is certainly wrong: a source already standing at its post-generation status has + * already been generated from, so a second click - or a second POST to the endpoint - would mint a + * duplicate document (a second invoice for the same proforma). The completion hook declares the + * "already done" status; nothing about it said the action was finished, which is why the button + * kept working and kept charging the customer twice. + * + *

+ * It gates the CLICK, not the event trigger: an event-driven create-from carries its own + * at-most-once back-reference guard (or asks for a row per event with {@code mode: append}), and + * qualifies its moment with the {@code event.when} status guard. + */ + private List fromStatus; + /** Target property -> source property (a field or to-one relation name of {@link #from}). */ private Map map = new LinkedHashMap<>(); @@ -367,6 +388,19 @@ public void setSourceStatus(Integer sourceStatus) { this.sourceStatus = sourceStatus; } + public List getFromStatus() { + return fromStatus; + } + + public void setFromStatus(List fromStatus) { + this.fromStatus = fromStatus; + } + + /** Whether an explicit list of allowed SOURCE statuses is declared (see {@link #fromStatus}). */ + public boolean hasFromStatus() { + return fromStatus != null && !fromStatus.isEmpty(); + } + public Integer getSourceStatusOnRetire() { return sourceStatusOnRetire; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 2471cfb23db..090b30e0cb4 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -6818,12 +6818,69 @@ private static void validateGenerates(IntentModel model, Set entityNames validateMapTarget(crossModel || items.getTo() == null ? null : byName.get(items.getTo()), items.getMap(), "generates [" + name + "]", "items map", issues); } + validateGeneratesFromStatus(g, name, byName, crossModelSource, issues); validateGeneratesItemLines(g, name, source, byName, model.getEntities(), crossModel, issues); validateGeneratesPrompt(g, name, byName, crossModel, issues); validateGeneratesReopen(g, name, byName, crossModel, model, issues); } } + /** + * Validate the from-status guard of a create-from (issue #7068): the statuses the SOURCE may stand + * in for the action to run at all. + * + *

+ * It is refused where it could not be evaluated or could not mean anything: a source with no + * {@code function: EntityStatus} relation has no column to read, a {@code page}-scoped action has + * no record to read it from, and an allow-list containing the {@code sourceStatus} the action + * itself writes re-opens exactly the duplicate the guard exists to refuse - the second click would + * find the source in an allowed status again and mint a second document. + */ + private static void validateGeneratesFromStatus(GeneratesIntent g, String name, Map byName, + boolean crossModelSource, List issues) { + if (!g.hasFromStatus()) { + return; + } + if (!"entity".equals(g.getScope())) { + issues.add("generates [" + name + "] declares fromStatus but its scope is [" + g.getScope() + + "] - a status guard reads the status of the record the action runs on, and a page-scoped action has none"); + } + if (!g.hasButton()) { + issues.add("generates [" + name + "] declares fromStatus but contributes no button (it is event-driven only)" + + " - the guard is on the click; qualify the moment with the event's when: guard instead," + + " or add button: true to keep the click and its guard"); + } + for (Integer status : g.getFromStatus()) { + if (status == null) { + issues.add("generates [" + name + "] fromStatus has an empty entry - list the status seed ids (or their seeded names)" + + " the source may stand in"); + } + } + if (g.getSourceStatus() != null && g.getFromStatus() + .contains(g.getSourceStatus())) { + issues.add("generates [" + name + "] lists its own sourceStatus [" + g.getSourceStatus() + + "] among the allowed fromStatus values - the completion hook moves the source there once the target exists," + + " so allowing it back is a second document from the same source; drop it from fromStatus"); + } + if (crossModelSource) { + return; // the source's relations live in the owner .model - resolved at generation time + } + EntityIntent from = g.getFrom() == null ? null : byName.get(g.getFrom()); + if (from == null) { + return; // the bad reference is already reported + } + boolean hasStatus = false; + for (RelationIntent relation : from.getRelations()) { + if (relation.isEntityStatus()) { + hasStatus = true; + } + } + if (!hasStatus) { + issues.add("generates [" + name + "] fromStatus requires the from entity [" + g.getFrom() + + "] to declare a function: EntityStatus relation"); + } + } + /** * Validate the declared reopen of a create-from (issue #6868): {@code sourceStatusOnRetire}, the * INVERSE of the {@code sourceStatus} completion hook - the status the SOURCE returns to when the diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index cf83ce25acd..a6bfd8d18a7 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -279,10 +279,10 @@ private void rewritePostings(Map root) { /** * Every status a create-from names, all three on the SOURCE's nomenclature: the {@code event} guard - * it qualifies on (issue #6711, exactly as a posting's does), the {@code sourceStatus} completion - * hook it flips to once the target exists, and the {@code sourceStatusOnRetire} the retirement of - * that target returns it to (issue #6868). The source is {@code from:}, owned by {@code fromUses:} - * when it is not local. + * it qualifies on (issue #6711, exactly as a posting's does), the {@code fromStatus} guard the + * click has to satisfy (issue #7068), the {@code sourceStatus} completion hook it flips to once the + * target exists, and the {@code sourceStatusOnRetire} the retirement of that target returns it to + * (issue #6868). The source is {@code from:}, owned by {@code fromUses:} when it is not local. */ private void rewriteGenerates(Map root) { for (Object node : asList(root.get("generates"))) { @@ -297,6 +297,7 @@ private void rewriteGenerates(Map root) { if (event != null && event.get("when") != null) { rewriteWhen(event, statusRelationName(source), status, subject + " event when"); } + putResolvedList(generate, "fromStatus", status, subject + " fromStatus"); putResolved(generate, "sourceStatus", status, subject + " sourceStatus"); putResolved(generate, "sourceStatusOnRetire", status, subject + " sourceStatusOnRetire"); } diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index ab728b28e15..c6c9a873b5c 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -1683,6 +1683,10 @@ generates: map: Description: Description Amount: Amount + fromStatus: [CONFIRMED] # optional guard: the SOURCE statuses the action may run from + # (409 + the button hides elsewhere). A declared sourceStatus + # IMPLIES this guard against itself - no second invoice from an + # already-invoiced proforma. sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus seed id # after the target is created (e.g. proforma -> INVOICED) sourceStatusOnRetire: 2 # optional INVERSE of that hook: where the SOURCE returns when the diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java index 879ffdd0216..74f092bd217 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java @@ -307,6 +307,68 @@ void completionHookResolvesTheSourceStatusRelation() { assertEquals("Status", g.get("sourceStatusProperty")); assertEquals("3", g.get("sourceStatusValue")); assertEquals("Proforma", g.get("fromPerspective")); + + // ...and the completion hook IMPLIES the from-status guard (issue #7068): a proforma already + // standing at the status the hook writes has been invoiced, so the endpoint refuses the run. + assertEquals(true, g.get("hasStatusGuard")); + assertEquals("Status", g.get("guardStatusProperty")); + assertEquals("currentStatus != 3", g.get("guardStatusExpr")); + assertEquals("3", g.get("guardStatuses")); + } + + @Test + void authoredFromStatusBecomesTheAllowList() { + IntentModel model = IntentParser.parse(""" + name: sales + entities: + - name: ProformaStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Proforma + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + relations: + - { name: Status, kind: manyToOne, to: ProformaStatus, function: EntityStatus, init: 1 } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + generates: + - name: invoice-from-proforma + from: Proforma + to: Invoice + forEntity: Proforma + fromStatus: [CONFIRMED] + sourceStatus: INVOICED + seeds: + - name: proforma-statuses + entity: ProformaStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: CONFIRMED } + - { id: 3, name: INVOICED } + """); + Map g = GlueIntentGenerator.buildGeneratesForTest(model) + .get(0); + + // The authored list wins over the implied deny-list, and the names are seed ids by now. + assertEquals(true, g.get("hasStatusGuard")); + assertEquals("currentStatus == 2", g.get("guardStatusExpr")); + assertEquals("2", g.get("guardStatuses")); + assertTrue(String.valueOf(g.get("guardStatusText")) + .contains("allowed only from status [2]")); + } + + @Test + void aCreateFromWithNoStatusAtAllKeepsNoGuard() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(YAML)) + .get(0); + + assertEquals(false, g.get("hasStatusGuard")); + assertEquals("", g.get("guardStatusExpr")); } @SuppressWarnings("unchecked") diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java index 7987b03c791..f43e737b036 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java @@ -14,6 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; + import org.eclipse.dirigible.components.intent.model.GeneratesIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.junit.jupiter.api.Test; @@ -1081,6 +1083,67 @@ void rejectsAPromptOnAnAppendingCreateFrom() { "got: " + ex.getIssues()); } + /** + * The explicit half of the from-status guard (issue #7068): the statuses the source may stand in + * for the create-from to run, named rather than numbered. + */ + @Test + void aDeclaredFromStatusParses() { + IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD + """ + map: { Fine: id } + fromStatus: [IDENTIFIED] + sourceStatus: DECLARED + """); + GeneratesIntent g = model.getGenerates() + .get(0); + assertTrue(g.hasFromStatus()); + assertEquals(List.of(2), g.getFromStatus()); + } + + /** + * Allowing the status the completion hook itself writes re-opens the duplicate the guard exists to + * refuse: the second click finds the source in an allowed status again. + */ + @Test + void rejectsAFromStatusThatIncludesTheCompletionHooksOwnStatus() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + map: { Fine: id } + fromStatus: [IDENTIFIED, DECLARED] + sourceStatus: DECLARED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("fromStatus") && i.contains("sourceStatus")), + "got: " + ex.getIssues()); + } + + /** A page-scoped action runs on the view, so there is no record whose status could be read. */ + @Test + void rejectsAFromStatusOnAPageScopedAction() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + scope: page + map: { Fine: id } + fromStatus: [IDENTIFIED] + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("fromStatus") && i.contains("page")), + "got: " + ex.getIssues()); + } + + /** Nothing to read the guard from: the source declares no EntityStatus relation. */ + @Test + void rejectsAFromStatusOnASourceWithNoStatusRelation() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + map: { Claim: id } + fromStatus: [1] + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("fromStatus") && i.contains("EntityStatus")), + "got: " + ex.getIssues()); + } + /** * The whole point of the key (issue #6868): the source's completion flip is INVERTED when the * target it produced is retired, so the ordinary trigger re-fires and mints the replacement. Both diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index 0793195b0e4..e546d464b56 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -648,7 +648,12 @@ private static void bindGenerate(Map item, Map c "hasReopen", "reopenStatusValue", "reopenRetiredCondition", "toPerspective", // The declared input form (issue #6685): the prompted target properties with their // pre-rendered value conversions - the template renders one block per entry. - "hasPrompt", "promptFields"); + "hasPrompt", "promptFields", + // The from-status guard (issue #7068): the pre-rendered boolean over an int + // currentStatus local, the status FK it reads and the human half of the 409. Gated on + // the boolean - a .glue written before these keys existed carries none and renders the + // unguarded run() it always had. + "hasStatusGuard", "guardStatusProperty", "guardStatusExpr", "guardStatusText", "guardStatuses"); // The topic the listener binds is the glue's to state and the template's to emit verbatim - but // a .glue written before the step axis (issue #6800) carries no suffix at all, and a bare // reference renders as its own literal into a destination nothing ever publishes on. An absent diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/customActions.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/customActions.js index f29214feec7..68cf9079eb0 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/customActions.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/customActions.js @@ -28,7 +28,8 @@ * * It is a global Alpine store so every generated view reads it the same way: * $store.customActions.getActions(perspective, view, 'page') -> the view's toolbar actions - * $store.customActions.getActions(perspective, view, 'entity') -> the view's per-record actions + * $store.customActions.getActions(perspective, view, 'entity', record) -> the view's per-record actions, + * minus those the record's status bars * $store.customActions.trigger(action, id) -> open the action page in the * app-wide dialog (an entity action * passes the record id as ?id=) @@ -133,11 +134,37 @@ document.addEventListener('alpine:init', () => { // is 'page' (a toolbar action on the whole view; matches page or an unset type) or 'entity' (a // per-record action). Contributors set `view`/`type` on the descriptor (a `perspective` field, if // present, stays informational). The list arrives already order-sorted from the endpoint. - getActions(view, type) { + // An entity action may carry a `guard` - the statuses of the record it is offered on (issue + // #7068). Pass the selected record as `record` and a guarded action disappears while the record + // stands in a status it would refuse: a proforma already INVOICED stops carrying a live "Generate + // Invoice" button. The record is optional, so a caller that has none (a page action, a shell that + // never loaded the row) sees exactly what it saw before - the generated controller's 409 is the + // contract, this only stops offering the click. + getActions(view, type, record) { return (this.actions || []).filter((a) => a && a.view === view && (type === 'entity' ? a.type === 'entity' - : (a.type === 'page' || a.type === undefined || a.type === null))); + : (a.type === 'page' || a.type === undefined || a.type === null)) && + this.isAvailable(a, record)); + }, + + // Whether a guarded action is offered on this record. An absent guard, an absent record or a + // record carrying no value for the guarded property all leave the action visible - hiding a + // button on a value we do not have is how an action vanishes for no reason the user can see. + isAvailable(action, record) { + const guard = action && action.guard; + if (!guard || !guard.property || !record) return true; + const raw = record[guard.property]; + if (raw === null || raw === undefined || raw === '') return true; + const current = Number(raw); + if (Number.isNaN(current)) return true; + if (Array.isArray(guard.allowed) && guard.allowed.length) { + return guard.allowed.some((s) => Number(s) === current); + } + if (Array.isArray(guard.blocked) && guard.blocked.length) { + return !guard.blocked.some((s) => Number(s) === current); + } + return true; }, // Trigger a contributed action. Two flavours, decided by the descriptor: diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template index f786df57529..08cb8d4b522 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Generate.java.template @@ -11,7 +11,7 @@ import org.eclipse.dirigible.sdk.utils.Json; #if($hasItemLines) import org.eclipse.dirigible.sdk.utils.Calc; #end -#if($hasPrompt) +#if($hasPrompt || $hasStatusGuard) import org.eclipse.dirigible.sdk.http.Response; #end @@ -51,6 +51,15 @@ import org.eclipse.dirigible.sdk.http.Response; * A create-from with no event trigger deliberately keeps NO such guard - minting several targets from * one source is a legitimate manual act. #end +#end +#if($hasStatusGuard) + * + * From-status guard (intent `fromStatus:`, or the one implied by `sourceStatus:` - issue #7068): the + * endpoint refuses the run with 409 unless the source stands in a status the action accepts + * [${guardStatuses}], and the contributed button stops offering itself in the others. A create-from + * with a completion hook flips its source and then went on offering the same button on the flipped + * record, so a second click minted a second document - another invoice for a proforma already invoiced. + * The guard is on the CLICK: an event-driven run carries its own at-most-once back-reference guard. #end */ #if(!$eventOnly) @@ -83,6 +92,21 @@ public class ${className}Generate { return "{\"error\": \"missing required input [${p.prop}]\"}"; } #end +#end +#if($hasStatusGuard) + // The from-status guard, asked BEFORE anything is created and only on this path: the click. + // Calc reads the status FK with the calculated-field semantics (null -> 0, never a seed id). + gen.${fromGenFolder}.data.${fromJavaPerspective}.${fromEntity}Entity guarded = + new gen.${fromGenFolder}.data.${fromJavaPerspective}.${fromEntity}Repository().findById(req.id); + if (guarded == null) { + Response.setStatus(404); + return "{\"error\": \"source ${fromEntity} not found\"}"; + } + int currentStatus = org.eclipse.dirigible.sdk.utils.Calc.eval("${guardStatusProperty}", guarded, 0).intValue(); + if (!(${guardStatusExpr})) { + Response.setStatus(409); + return "{\"error\": \"${name} ${guardStatusText} - current status is [" + currentStatus + "]\"}"; + } #end gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity saved = create(req.id#if($hasPrompt), req.values#end); if (saved == null) { diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template index 0416d007141..b2ce94c0805 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template @@ -555,7 +555,7 @@ #end -