From 48664c21d4f620511569245eadab6d6a384ec40c Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 18 Aug 2026 16:57:47 +0300 Subject: [PATCH 1/2] feat(intent): a create-from on the step axis, and an opt-in append cardinality (#6800) "On event E, append a derived row" - a log entry per process step, a protocol line per status change - had no expression: postings/posts are idempotent per source, rollups/aggregates recompute an existing row and never insert, and an event-driven create-from is at-most-once by construction. The shape needed a hand-written listener or an outbound -> inbound loopback whose only purpose was to defeat that guard. Two additions to the generates event map close it: - the process-step axis (onStepReached / onStepCompleted: { process, step }) that notifications, integrations and outbound departures already bind to, narrowed by one rule of its own - the process's trigger entity must be the from: entity, since the step event is about the record its process runs on and that record is what the create-from reads by id. The source must be local: a process and its steps belong to the model that declares them. when: stays optional - the step is the moment; - mode: once (default, unchanged) or append, which drops the existing-target lookup in Generate.java.template so every delivery creates a row. The back-reference stays required in both cardinalities - the dedup key under once, the created row's provenance under append. StepEventSupport.boundEvents had to learn about generates, not just the glue builder: emitters() reads that list, so a moment whose only consumer is a create-from would otherwise get no JavaDelegate emitter and the listener would bind a topic nothing published to. GlueGenerator (ide-template) binds the five new keys into the Velocity context - an unbound key renders as its own literal, which is how the listener's destination silently loses its suffix. append is the absence of a guard, not a state-aware one: delivery is at-least-once, so a redelivery appends a duplicate, and it is not the answer to "the target was voided, make another" (#6814). Two append rules sharing a target and a back-reference are legal by design, which is why #6813's collision diagnostic must be scoped to once pairs. --- components/engine/engine-intent/CLAUDE.md | 1 + .../intent/generator/GlueIntentGenerator.java | 28 ++- .../intent/generator/StepEventSupport.java | 12 +- .../intent/model/GeneratesIntent.java | 59 ++++- .../intent/parser/IntentParser.java | 106 +++++++-- .../intent/parser/UnknownKeyValidator.java | 7 +- .../main/resources/intent-assistant-guide.md | 75 +++++- .../intent/generator/GlueGeneratesTest.java | 105 ++++++++ .../intent/parser/GeneratesIntentTest.java | 224 ++++++++++++++++++ .../template/service/model/GlueGenerator.java | 7 +- .../events/Generate.java.template | 11 +- .../events/GenerateOnEvent.java.template | 27 ++- .../tests/api/IntentEmissionCoverageIT.java | 180 ++++++++++++++ 13 files changed, 787 insertions(+), 55 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 16987dfcca4..243556e300f 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -410,6 +410,7 @@ Semantics worth knowing: - **A notify body carries LINKS, and the intent never spells a route (`{recordUrl}` / `{inboxUrl}`, #6553).** `{appUrl}` (#6642) supplies only the origin, so "you have an approval waiting" still had to hand-type `{appUrl}/services/web//gen//index.html#/Order/{id}/edit` into the body - the generated app's URL layout, typed into the one artifact that is forbidden to know it, and silently stale the day a template changes it. **`{recordUrl}`** is the deep link to the record the message is about and **`{inboxUrl}`** the link to the recipient's process Inbox; both are reserved tokens resolving to a **bare Java identifier** - `NotificationSupport.Resolver` emits the name and records the use, `NotifySupport.deepLinkFields` carries the only facts the intent owns (`recordUrlEntity` / `recordUrlKeyProperty` - model facts, not paths) plus a `uses*` flag per link, and the **events template DECLARES the local**, composing `/services/web//gen//index.html#///edit` from the parameters it already has. That split is the whole design: it is the template layer that knows the routes (the same reason `Trigger.java.template`, not the generator, assembles `__entityUrl`), so the path-agnostic rule holds and a route change stays a template change. A link is declared only where the message names it (no dead local), and one **fan-out** links the ROW like every other bare path - `{record.}` reaches the anchor for VALUES, but there is deliberately no anchor LINK. All four notify call sites got it from the two shared seams (`buildNotifications`, `buildSchedules`, `notifyFields` -> transitions + sends), so no call site can be forgotten. Covered by `NotificationSupportTest`, `IntentEngineIT` (both links, and the unused one NOT declared) and `IntentEmissionCoverageIT` - where it is also the compile proof: an undeclared local fails the whole client-Java batch and every REST assertion in that gate. - **`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). 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. +- **`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). - **`history: true` on an entity = the shadow change trail (#6715).** `audit: true` keeps only the LAST writer and time, in four columns of the row itself; a regulated domain has to answer *what changed, from what to what, by whom, when* for every write, and that was hand-written or skipped. `history: true` gives the entity a sibling **`_HISTORY`** shadow table (the `_LANG` pattern: emitted by `application.schema.template` off the EDM `history="true"` attribute `EdmIntentGenerator` writes) shaped `GUID, Id, Operation, Property, OldValue, NewValue, ChangedAt, ChangedBy, Source`, and the generated repository appends **one row per property whose value actually changed** on every write path it owns — create (`null -> value`), update, `updateWithoutEvent`, the targeted `updateProperty`/`updateProperties` (whose override is now gated on `history` too — the base ones write the column directly and would leave no trace), `recalculate` (it deliberately calls the BASE targeted write, so it records for itself) and delete (`value -> null`). The writer is the SDK `org.eclipse.dirigible.sdk.db.History` (api-modules-java, `Translator`'s sibling: plain JDBC, quoted exact-case identifiers, values stringified and truncated at 4000). Four decisions worth keeping: **(1) `Source` is `USER` vs `SYSTEM`** — the user-facing paths record USER, every targeted/system write records SYSTEM, because once a roll-up total and a person's edit land in the same column nothing downstream can tell them apart. **(2) The before-image is read through `super.findById`, never the class's own override** — on a multilingual entity the override overlays the caller's language, and a translated value diffed against the stored one reports an edit nobody made. **(3) Decimals are compared by `compareTo`, not `equals`** — a recomputed `2.0` against a stored `2.00` is the same amount, and treating it as a change fills the trail with noise. **(4) The tracked set excludes the primary key and the audit columns** (they say exactly what the row itself says). Read-only end to end: `GET /{id}/history` on the entity's own controller (404 on an unknown row — never an empty trail a caller could read as "nothing happened"), rendered as a **History** card in the manage form's and the document's right sidebar; there is no create/update/delete verb on the shadow table anywhere, which is what makes it append-only *by construction* rather than by policy. Two interactions are deliberately specified: the **scoped surfaces get no history endpoint at all** (a `my`/`partner` controller strips `sensitive:` fields from its responses, so handing it a trail carrying those fields' old and new values would leak exactly what the scoping hides — when a scoped panel is wanted it arrives WITH its per-property filter, in one PR), and **CSVIM seeds bypass the repository**, so seeded rows have no history (correct: nobody wrote them). The append happens after the entity write, on its own connection — `JavaEntityStore` commits every operation in its own transaction, so there is no enclosing transaction to join; a failure to append is logged at ERROR and does not fail the already-committed business write. `IntentEmissionCoverageIT` covers all of it (`Entry` for the USER/SYSTEM runtime split, `Claim` for the audit-exclusion and the absent personal endpoint). - **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` declares which languages this module PROVIDES translations for (landing on the `.model` root → Harmonia `config.js` `languages`) — it never defines what the stack supports: the **Region & Language** picker always offers the PLATFORM's set (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en`, tenant-overridable via the tenant configuration, served by `platform-core/services/application-languages.js`), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. The application shell compares each app's provided set against the platform set and lists gaps as warnings in Settings; untranslated content falls back to the default language. **A report reads the same data, so it reads it in the same language (#6544).** The overlay above is a Java read-time merge, and a report never loads an entity — it is raw SQL over the base tables, so a report column bound to a translatable property used to render the BASE value right next to a list page rendering the translated one (a status column reading `DRAFT` beside a list reading the translated term, from the same record). `ReportIntentGenerator.translate` therefore does the overlay IN THE QUERY: a translated dimension becomes `COALESCE(_LANG."", ."")` over a `LEFT JOIN "
_LANG"` keyed on the base row and `:language` — a bound parameter, never interpolated, which the generated report repository fills from `User.getLanguage()`. Three things make it cheap: it rides entirely on the two round-trip-safe carriers the `.report` already has (a `joins[]` row whose `type` is `LEFT`, and the column's verbatim `expression`), so the **report editor needed no change** and a generated report still opens in the builder's structured mode (`ReportEditorRoundTripTest`); the repository template keys its binding off the QUERY (`#if($query.contains(":language"))`) rather than a model flag, so nothing can go stale and a hand-authored report can use `:language` too; and the target's translatable property set is read from the owner `.model` for a **cross-model** nomenclature (`CrossModelSupport.TargetInfo.translatedProperties`, mirroring the schema template's column rule), which is the common case since nomenclatures usually live in their own module. **Only the SELECT list is overlaid** — `filter:`, the lifecycle `scope:` and the per-column report filters compile against the BASE table, which is exactly why translating a nomenclature can never change what a report matches. Aggregates are not overlaid either (a `min`/`max` over a translated string would pick a different row per language). Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. 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 4ad6244f5e3..06f2f705bd5 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 @@ -856,18 +856,20 @@ private static List> buildGenerates(IntentModel model, Map * The back-reference is DERIVED from the {@code map} entry that copies the source's primary key * rather than declared a second time: the mapping already says which target property points back at * the source, and two ways to say it could only drift. A missing one fails loudly here because - * without it the create-from has no way to recognize its own output, so an event redelivery would - * mint a duplicate document (the parser catches the local case earlier, with the fix in the - * message). + * without it the create-from has no way to recognize its own output (under {@code mode: once} an + * event redelivery would mint a duplicate document; under {@code mode: append} the appended row + * would not say what it is about) - the parser catches the local case earlier, with the fix in the + * message. * * @param g the create-from * @param e the glue entry being built @@ -877,6 +879,9 @@ private static void putGeneratesEvent(GeneratesIntent g, Map e, e.put("hasEvent", g.isEventDriven()); if (!g.isEventDriven()) { e.put("isCreate", false); + e.put("isStep", false); + e.put("topicSuffix", ""); + e.put("appendMode", false); e.put("guardProperty", ""); e.put("guardValue", ""); e.put("backRefProperty", ""); @@ -885,6 +890,15 @@ private static void putGeneratesEvent(GeneratesIntent g, Map e, boolean isCreate = g.getEvent() .get("onCreate") != null; e.put("isCreate", isCreate); + StepEventSupport.Binding step = StepEventSupport.binding(g.getEvent()); + e.put("isStep", step != null); + e.put("stepProcess", step == null ? "" : step.process()); + e.put("stepName", step == null ? "" : step.step()); + e.put("topicSuffix", + step != null ? StepEventSupport.topicSuffix(step.process(), step.step(), step.kind()) : isCreate ? "" : "-transitioned"); + // The cardinality (#6800): `append` drops the existing-target lookup in the create-from, so + // every delivery of the event creates a row. It is the absence of a guard, not another guard. + e.put("appendMode", g.isAppendMode()); String guardProperty = ""; String guardValue = ""; Object whenValue = g.getEvent() diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java index 069a92d61fc..5d414e5396c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StepEventSupport.java @@ -17,6 +17,7 @@ import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.GeneratesIntent; import org.eclipse.dirigible.components.intent.model.IntegrationIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.NotificationIntent; @@ -35,8 +36,9 @@ * the id in the (clear-D) process context and publishes its JSON to a step-scoped topic. That topic * is the entity's own topic plus {@link #topicSuffix(Map) a step suffix}, so every consumer the * lifecycle events already feed - notifications with their relation loads, guards and print - * attachments, integrations, outbound departures - binds to it and reads the payload unchanged. The - * action vocabulary is therefore reused literally, not re-implemented per event kind. + * attachments, integrations, outbound departures, event-driven create-froms - binds to it and reads + * the payload unchanged. The action vocabulary is therefore reused literally, not re-implemented + * per event kind. * *

* The record a step event is about is the process's trigger entity: a process runs on one record, @@ -261,6 +263,12 @@ private static List> boundEvents(IntentModel model) { for (OutboundIntent outbound : model.getOutbound()) { events.add(outbound.getEvent()); } + // A create-from bound to a step (#6800) is a consumer like any other, and may well be the ONLY + // one of that moment - without it here the moment would have no emitter and the listener would + // wait on a topic nobody publishes to. + for (GeneratesIntent generates : model.getGenerates()) { + events.add(generates.getEvent()); + } return events; } 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 f948f2830d9..1e89143d39b 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 @@ -55,6 +55,14 @@ */ public class GeneratesIntent { + /** + * The default event cardinality: at most one target row per source, guarded by the back-reference. + */ + public static final String MODE_ONCE = "once"; + + /** The opt-in event cardinality: one target row per delivered event, with no guard at all. */ + public static final String MODE_APPEND = "append"; + /** * Unique name within the model; drives the action id, the contribution files and the controller. */ @@ -106,17 +114,29 @@ public class GeneratesIntent { /** * Optional event trigger (issue #6711): the create-from runs by itself when the SOURCE reaches a - * state, instead of waiting for a click. {@code onTransition} (a status write; a {@code when} - * status guard is mandatory) or {@code onCreate} (the source's insert; the guard is optional) names - * the source entity - the same one {@link #from} declares, repeated for symmetry with - * {@code postings}' event axis and validated against it. The owning model is NOT repeated here: - * {@link #fromUses} already declares it. + * moment, instead of waiting for a click. Exactly one of two axes, the same pair the rest of the + * declarative glue binds to: + *

    + *
  • the source's lifecycle - {@code onTransition} (a status write; a {@code when} status + * guard is mandatory) or {@code onCreate} (the source's insert; the guard is optional), naming the + * source entity - the same one {@link #from} declares, repeated for symmetry with {@code postings}' + * event axis and validated against it; and
  • + *
  • a process step (issue #6800) - {@code onStepReached} / {@code onStepCompleted}: + * { process, step }, the axis {@code notifications} / {@code integrations} / + * {@code outbound} already use, whose record is the process's trigger entity (which must be + * {@link #from}). The {@code when} guard stays optional: the step IS the moment.
  • + *
+ * The owning model is NOT repeated here: {@link #fromUses} already declares it. * *

- * An event-driven create-from is at-most-once: the target's back-reference to the source - * (the {@link #map} entry copying the source's primary key) is checked before anything is created, - * so an event redelivery - and a click on a button that is still declared - is a no-op that returns - * the document that already exists. + * {@code mode} declares the cardinality. The default {@code once} is at-most-once: the + * target's back-reference to the source (the {@link #map} entry copying the source's primary key) + * is checked before anything is created, so an event redelivery - and a click on a button that is + * still declared - is a no-op that returns the document that already exists. {@code append} drops + * that lookup, so every delivery of the event creates a row: the "one log/protocol row per step, + * per transition" shape. It is the ABSENCE of a guard, not a state-aware one - a redelivery appends + * a duplicate, and a replacement for a voided target is not what it expresses. The back-reference + * is required in BOTH modes: the dedup key in {@code once}, the row's provenance in {@code append}. */ private Map event; @@ -267,6 +287,27 @@ public boolean isEventDriven() { return event != null && !event.isEmpty(); } + /** + * The declared cardinality of the event trigger (see {@link #event}). + * + * @return {@link #MODE_APPEND} when the author asked for a row per event, {@link #MODE_ONCE} + * otherwise (the default, and the value for a create-from with no event at all) + */ + public String getEventMode() { + Object mode = event == null ? null : event.get("mode"); + String declared = mode == null ? null + : mode.toString() + .trim(); + return declared == null || declared.isEmpty() ? MODE_ONCE : declared; + } + + /** + * @return whether every delivery of the event appends a target row (no at-most-once guard) + */ + public boolean isAppendMode() { + return MODE_APPEND.equals(getEventMode()); + } + public Boolean getButton() { return button; } 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 df40be8c865..67988725414 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 @@ -5338,7 +5338,7 @@ private static void validateGenerates(IntentModel model, Set entityNames if (!"entity".equals(scope) && !"page".equals(scope)) { issues.add("generates [" + name + "] has invalid scope [" + scope + "] (expected 'entity' or 'page')"); } - validateGeneratesEvent(g, name, source, crossModelSource, issues); + validateGeneratesEvent(g, name, source, crossModelSource, model, issues); validateMapSource(source, g.getMap(), "generates [" + name + "]", "map", issues); if (g.getItems() != null) { GeneratesItemsIntent items = g.getItems(); @@ -5441,16 +5441,21 @@ private static void validateGeneratesPrompt(GeneratesIntent g, String name, Map< } /** - * Validate the optional {@code event} trigger of a create-from (issue #6711): exactly one of - * {@code onTransition} (a status write - the {@code when} status guard is mandatory) or - * {@code onCreate} (the source's insert - the guard is optional), naming the SAME entity - * {@code from} declares; the owning model is never repeated here, {@code fromUses} declares it. An - * event-driven create-from is at-most-once, and the guard is the target's back-reference to the - * source - so the {@code map} must copy the source's primary key onto it. Without an event, - * {@code button: false} is rejected: a create-from with neither trigger generates nothing at all. + * Validate the optional {@code event} trigger of a create-from (issues #6711, #6800): exactly one + * of the source's lifecycle ({@code onTransition} - a status write, the {@code when} status guard + * is mandatory - or {@code onCreate} - the source's insert, the guard optional), naming the SAME + * entity {@code from} declares, or a process step ({@code onStepReached}/{@code onStepCompleted}: + * { process, step }) whose process runs ON that entity. The owning model is never + * repeated here, {@code fromUses} declares it. + * + *

+ * The {@code map} must copy the source's primary key onto the target's back-reference in BOTH + * cardinalities: it is the at-most-once guard of the default {@code mode: once}, and the row's + * provenance under {@code mode: append}. Without an event, {@code button: false} is rejected: a + * create-from with neither trigger generates nothing at all. */ private static void validateGeneratesEvent(GeneratesIntent g, String name, EntityIntent source, boolean crossModelSource, - List issues) { + IntentModel model, List issues) { String subject = "generates [" + name + "]"; if (!g.isEventDriven()) { if (Boolean.FALSE.equals(g.getButton())) { @@ -5463,11 +5468,21 @@ private static void validateGeneratesEvent(GeneratesIntent g, String name, Entit if (event.get("model") != null) { issues.add(subject + " event must not declare model: - the source and its owning model are declared by from:/fromUses:"); } + validateGeneratesEventMode(g, subject, issues); Object onTransition = event.get("onTransition"); Object onCreate = event.get("onCreate"); - if (onTransition == null && onCreate == null) { - issues.add(subject + " event requires `onTransition: " + g.getFrom() + "` (a status write) or `onCreate: " + g.getFrom() - + "` (the source's insert)"); + String stepKind = null; + for (String kind : STEP_EVENT_KINDS) { + if (event.get(kind) != null) { + stepKind = kind; + } + } + if (stepKind != null) { + validateGeneratesStepEvent(g, subject, stepKind, onTransition != null || onCreate != null, crossModelSource, model, issues); + } else if (onTransition == null && onCreate == null) { + issues.add(subject + " event requires `onTransition: " + g.getFrom() + "` (a status write), `onCreate: " + g.getFrom() + + "` (the source's insert) or `onStepReached`/`onStepCompleted: { process: , step: }`" + + " (a moment in a process that runs on it)"); } else if (onTransition != null && onCreate != null) { issues.add(subject + " event declares both onTransition and onCreate - exactly one trigger is allowed"); } else { @@ -5488,14 +5503,73 @@ private static void validateGeneratesEvent(GeneratesIntent g, String name, Entit issues.add(subject + " event when [" + when + "] must be ` == `"); } } - // The at-most-once guard: the target's own to-one back to the source, written from the source's - // primary key. A cross-model source's key field is read from the owner .model at generation - // time, so only the local case is checkable here - the glue generator fails loudly for the rest. + // The back-reference: the target's own to-one back to the source, written from the source's + // primary key. Required in BOTH cardinalities - the at-most-once guard under `once`, the row's + // provenance under `append` (a log row nothing points back at cannot be read). A cross-model + // source's key field is read from the owner .model at generation time, so only the local case + // is checkable here - the glue generator fails loudly for the rest. if (!crossModelSource && source != null && !g.getMap() .containsValue(seedIdField(source))) { issues.add(subject + " is event-driven, so its map must copy the source's [" + seedIdField(source) + "] onto the target's back-reference to it (e.g. `map: { " + g.getFrom() + ": " + seedIdField(source) - + " }`) - that back-reference is the at-most-once guard against an event redelivery"); + + " }`) - that back-reference is the at-most-once guard against an event redelivery under mode: once," + + " and the created row's provenance under mode: append"); + } + } + + /** + * The {@code mode} of an event trigger (issue #6800): {@code once} (the default - at most one + * target row per source) or {@code append} (a row per delivered event). Anything else is refused + * rather than silently read as the default, which would turn a typo into a cardinality nobody + * authored. + */ + private static void validateGeneratesEventMode(GeneratesIntent g, String subject, List issues) { + Object mode = g.getEvent() + .get("mode"); + if (mode == null) { + return; + } + String declared = String.valueOf(mode) + .trim(); + if (!GeneratesIntent.MODE_ONCE.equals(declared) && !GeneratesIntent.MODE_APPEND.equals(declared)) { + issues.add(subject + " event has invalid mode [" + declared + "] (expected '" + GeneratesIntent.MODE_ONCE + "' - at most one " + + g.getTo() + " per " + g.getFrom() + " - or '" + GeneratesIntent.MODE_APPEND + "' - one per delivered event)"); + } + } + + /** + * A create-from bound to a process step: the step must be an observable moment of a process that + * runs ON the source (the step event is delivered as a message about the process's trigger entity, + * which is what the create-from then reads by id), and the source must be local - a process and its + * steps belong to the model that declares them, so a cross-model source has none to bind to here. + * The {@code when} guard stays optional: the step already IS the moment. + */ + private static void validateGeneratesStepEvent(GeneratesIntent g, String subject, String kind, boolean lifecycleToo, + boolean crossModelSource, IntentModel model, List issues) { + if (lifecycleToo) { + issues.add(subject + " event declares " + kind + " next to onTransition/onCreate - exactly one trigger is allowed"); + return; + } + if (crossModelSource) { + issues.add(subject + " event binds " + kind + " on a cross-model source (fromUses [" + g.getFromUses() + + "]) - a process and its steps are local to the model that declares them; bind to onTransition/onCreate instead"); + return; + } + String triggerEntity = validateStepEventBinding(g.getEvent(), kind, subject, model, issues); + if (triggerEntity == null) { + return; // already reported + } + if (g.getFrom() != null && !g.getFrom() + .isBlank() + && !triggerEntity.equals(g.getFrom())) { + issues.add(subject + " event " + kind + " names a process that runs on [" + triggerEntity + "], not on the from entity [" + + g.getFrom() + "] - a step event is about the record its process runs on, which is the record the create-from reads"); + } + Object when = g.getEvent() + .get("when"); + if (when != null && !String.valueOf(when) + .matches("\\s*\\w+\\s*==\\s*\\d+\\s*")) { + issues.add(subject + " event when [" + when + "] must be ` == `"); } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java index 0b8e8c1c428..85a882d1b13 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java @@ -87,7 +87,12 @@ final class UnknownKeyValidator { Map.entry("OutboundIntent#event.onStepCompleted", Set.of("process", "step")), Map.entry("PostingIntent#event", Set.of("onTransition", "onCreate", "when", "model")), Map.entry("PostingIntent#rule", Set.of("entity", "match")), - Map.entry("GeneratesIntent#event", Set.of("onTransition", "onCreate", "when", "model")), + // Both axes plus the cardinality (#6800). Spelled out rather than reusing GLUE_EVENT_KEYS: + // a create-from binds onTransition (which no other consumer has) and never onUpdate/onDelete. + Map.entry("GeneratesIntent#event", + Set.of("onTransition", "onCreate", "onStepReached", "onStepCompleted", "when", "mode", "model")), + Map.entry("GeneratesIntent#event.onStepReached", Set.of("process", "step")), + Map.entry("GeneratesIntent#event.onStepCompleted", Set.of("process", "step")), Map.entry("GenerateChildIntent#forEach", Set.of("entity", "days", "model", "match")), Map.entry("ResolveIntent#event", Set.of("onCreate", "onUpdate", "when")), Map.entry("ResolveIntent#between", Set.of("start", "end", "value")), Map.entry("ResolveIntent#found", Set.of("setStatus")), 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 707570f1f01..a6cb5abe026 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 @@ -1483,16 +1483,58 @@ generates: amount: Amount ``` -- Exactly one of `onTransition` (a status write - a `when: " == "` guard is - **mandatory**, status by seeded NAME or id) or `onCreate` (the source's insert - the guard is optional, - for a source with no status lifecycle). The entity named there must be the SAME one `from:` declares: - the event says WHEN, `from:`/`fromUses:` say what and where. Never repeat the model as `model:`. -- **`map:` must copy the source's `id` onto the target's to-one back to the source.** That - back-reference is the at-most-once guard: before creating anything the create-from looks for a target - that already back-references this source and returns it instead, so an event redelivery - or a click - afterwards - is a no-op rather than a duplicate document. Authoring an event without it is rejected. +- Exactly one trigger, from either axis: + - the source's **lifecycle** - `onTransition` (a status write - a `when: " == "` + guard is **mandatory**, status by seeded NAME or id) or `onCreate` (the source's insert - the guard is + optional, for a source with no status lifecycle). The entity named there must be the SAME one `from:` + declares: the event says WHEN, `from:`/`fromUses:` say what and where. Never repeat the model as + `model:`. + - a **process step** - `onStepReached` / `onStepCompleted: { process: , step: }`, the + same event axis notifications / integrations / departures bind to (see "the event axis"). The process + must run ON the source (its `trigger:` entity is the `from:` entity - that record is what the step + event is about), the step must be a `userTask` or a `serviceTask`, and the source must be local (a + process and its steps belong to the model that declares them). `when:` stays optional here: the step + IS the moment. Use it when the follow-up document belongs to a point in a flow rather than to a + status - and as the route around a source whose write does not publish a transition. +- **`map:` must copy the source's `id` onto the target's to-one back to the source** - in BOTH + cardinalities. Under the default `mode: once` it is the at-most-once guard: before creating anything the + create-from looks for a target that already back-references this source and returns it instead, so an + event redelivery - or a click afterwards - is a no-op rather than a duplicate document. Under + `mode: append` it is the appended row's provenance. Authoring an event without it is rejected. +- **`mode:` - the cardinality.** `once` (default, today's behaviour) creates at most one target per + source. `append` creates one **per delivered event**: the "a row per step, a row per transition" shape - + a log entry, a protocol line, an activity record. + +```yaml +processes: + - name: ClaimApproval + trigger: { onCreate: Claim } + steps: + - { name: review, kind: userTask, args: { assignee: approver, form: ReviewClaim } } + - { name: activate, kind: serviceTask, args: { setRelationField: Status, value: ACTIVE } } + +generates: + # one LogEntry appended every time the activate step completes - several per Claim, by design + - name: log-activation + from: Claim + to: LogEntry + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: + Claim: id # back-reference: REQUIRED in both modes (the row's provenance here) + amount: amount + defaults: + step: "activate" # which moment this row records - a literal per generates block + date: now +``` + +- **`append` is the ABSENCE of a guard, not a state-aware one.** Every qualifying event appends a row, + including a redelivery (the step topic is published after commit and is not transactional with the + step - the same at-least-once contract the event axis states for `outbound`). It is therefore the wrong + answer to "I voided the document and cannot regenerate it": that needs a state-aware guard on + `mode: once`, not a cardinality that would also mint a document on every later event. Anything that + must exist at most once keeps `mode: once`. - **The button is dropped by default** (declaring an event is how you say nobody has to click). Add - `button: true` to keep both triggers; the button then shares the same at-most-once guard. + `button: true` to keep both triggers; the button then shares the cardinality of the event. - `sourceStatus:` composes normally (the flip happens after the target exists, and cannot re-trigger the create-from because the guard has already claimed the source). - Use this over `posts` when the result is a **document with line items**: `posts` writes flat mapped @@ -2144,9 +2186,10 @@ order. If a contract needs more than this it is an algorithm, not a payload - say so and hand off to a hand-written handler rather than stretching the block. -### the event axis - what a notification / integration / departure binds to +### the event axis - what a notification / integration / departure / create-from binds to -`notifications`, `integrations` and `outbound` departures each declare **exactly one** `event:`, either +`notifications`, `integrations`, `outbound` departures and event-driven `generates` create-froms each +declare **exactly one** `event:`, either - an **entity lifecycle** event - `{ onCreate: }` / `{ onUpdate: ... }` / `{ onDelete: ... }`; - a **process step** event - `{ onStepReached: { process: , step: } }` or @@ -2188,6 +2231,15 @@ event is about); the step must exist and be a `userTask` or a `serviceTask` (a d an end has no moment to observe). Any number of consumers may bind to the same step moment - the record is published once. +An event-driven **`generates`** create-from binds to the same axis, with one narrowing of its own: the +process must run on the create-from's `from:` entity (a create-from reads one source record, and the +step event is about the process's trigger record). It also adds its own `onTransition` lifecycle event - +see the `generates` section, together with `mode: once|append`. + +The step record is published **after commit** and is not transactional with the step, so every consumer +of the axis is at-least-once: a redelivery re-notifies, re-forwards, or (under `mode: append`) appends a +second row. + Every axis binding also takes an optional **`when:` guard** inside the `event:` map - a single comparison against a direct field of the record (`when: "channel != internal"`), which decides per record whether the reaction runs at all. @@ -2455,6 +2507,7 @@ name. - "on a schedule / every month, create a Y for each X / recurring invoices / auto-generate timesheets" -> **schedules** (`generate`) - "call an external API when X changes" -> **integrations** - "notify / call out when a task becomes available, or when a step is done" -> **notifications / integrations** with `event: { onStepReached | onStepCompleted: { process, step } }` +- "append a log / protocol / activity row every time a step completes (or a status is set)" -> **generates** with `event: { onStepCompleted: { process, step }, mode: append }` - "let an external system create X" -> **inbound** (`path` for HTTP, `source: { queue | topic }` for a message, `source: { folder, cron }` for dropped files) - "keep a running count of children on the parent" -> **rollups** - "expand a from-to span into day/week/month child rows / loan installments / vacation day items" -> **expansions** 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 be6bdea2d2a..f03be422a2f 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 @@ -123,6 +123,45 @@ class GlueGeneratesTest { - { id: 2, name: POSTED } """; + /** + * The step-axis + append shape of issue #6800: a log row appended every time the activate step of a + * process that runs on the source completes. + */ + private static final String STEP_APPEND_YAML = """ + name: claims + entities: + - name: Claim + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + - { name: amount, type: decimal } + - name: LogEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: step, type: string } + - { name: amount, type: decimal } + relations: + - { name: Claim, kind: manyToOne, to: Claim } + processes: + - name: ClaimApproval + trigger: { onCreate: Claim } + steps: + - { name: review, kind: userTask, args: { assignee: approver, next: activate } } + - { name: activate, kind: serviceTask, args: { setField: note, value: activated, next: done } } + - { name: done, kind: end } + generates: + - name: log-activation + from: Claim + to: LogEntry + forEntity: Claim + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: + Claim: id + Amount: amount + defaults: + Step: "activate" + """; + @SuppressWarnings("unchecked") @Test void rendersHeaderAssignmentsItemsAndKeys() { @@ -738,4 +777,70 @@ void noPromptLeavesTheFlagOff() { assertEquals(false, g.get("hasPrompt")); assertTrue(((List) g.get("promptFields")).isEmpty()); } + + /** + * A step-bound create-from (issue #6800) binds the step-scoped topic the generated emitter + * publishes the trigger entity on, and carries the append cardinality that drops the + * existing-target lookup. The payload shape is unchanged - the emitter publishes the same entity + * JSON a lifecycle event does. + */ + @Test + void aStepBoundAppendingGenerateCarriesTheStepTopicAndDropsTheLookup() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(STEP_APPEND_YAML)) + .get(0); + + assertEquals(true, g.get("hasEvent")); + assertEquals(true, g.get("isStep")); + assertEquals(false, g.get("isCreate")); + assertEquals("ClaimApproval", g.get("stepProcess")); + assertEquals("activate", g.get("stepName")); + assertEquals("-step-ClaimApproval-activate-completed", g.get("topicSuffix")); + // The cardinality: the create-from renders without its existing-target lookup. + assertEquals(true, g.get("appendMode")); + // The back-reference is still derived and emitted - the appended row's provenance. + assertEquals("Claim", g.get("backRefProperty")); + // The step IS the moment, so no per-record guard is required. + assertEquals("", g.get("guardProperty")); + } + + /** + * The step moment gets its emitter even when a create-from is its ONLY consumer - otherwise the + * listener would bind a topic nothing ever publishes to. + */ + @Test + void aGeneratesOnlyStepMomentStillGetsItsEmitter() { + List> stepEvents = GlueIntentGenerator.buildStepEventsForTest(IntentParser.parse(STEP_APPEND_YAML)); + + assertEquals(1, stepEvents.size()); + Map emitter = stepEvents.get(0); + assertEquals("ClaimApprovalActivateCompleted", emitter.get("className")); + assertEquals("Claim", emitter.get("entity"), "a step event is about the process's trigger entity"); + assertEquals("-step-ClaimApproval-activate-completed", emitter.get("topicSuffix")); + } + + /** + * The default cardinality and both lifecycle axes are unchanged: the same topics the create-from + * bound before {@code mode:}/the step axis existed, and the at-most-once lookup still rendered. + */ + @Test + void theLifecycleAxesAndTheDefaultCardinalityAreUnchanged() { + Map transitioned = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(EVENT_YAML)) + .get(0); + assertEquals("-transitioned", transitioned.get("topicSuffix")); + assertEquals(false, transitioned.get("isStep")); + assertEquals(false, transitioned.get("appendMode")); + + Map created = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse( + EVENT_YAML.replace("event: { onTransition: Fine, when: \"Status == POSTED\" }", "event: { onCreate: Fine }"))) + .get(0); + assertEquals("", created.get("topicSuffix"), "the platform publishes creates unsuffixed"); + assertEquals(false, created.get("appendMode")); + + Map clickOnly = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(YAML)) + .get(0); + assertEquals("", clickOnly.get("topicSuffix")); + assertEquals(false, clickOnly.get("isStep")); + assertEquals(false, clickOnly.get("appendMode")); + } + } 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 423c65945e0..6f8f37c478a 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 @@ -53,6 +53,44 @@ class GeneratesIntentTest { forEntity: Fine """; + /** + * The step-axis shape of issue #6800: a process that runs ON the create-from's source, and a log + * entity to append rows to. Ends at the {@code generates} entry's own keys, as the head above does. + */ + private static final String GENERATES_STEP_HEAD = """ + name: claims + entities: + - name: Claim + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + - { name: amount, type: decimal } + - name: LogEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: step, type: string } + - { name: amount, type: decimal } + relations: + - { name: Claim, kind: manyToOne, to: Claim } + processes: + - name: ClaimApproval + trigger: { onCreate: Claim } + steps: + - { name: review, kind: userTask, args: { assignee: approver, next: activate } } + - { name: activate, kind: serviceTask, args: { setField: note, value: activated, next: done } } + - { name: done, kind: end } + - name: LogReview + trigger: { onCreate: LogEntry } + steps: + - { name: check, kind: userTask, args: { assignee: approver, next: over } } + - { name: over, kind: end } + generates: + - name: log-activation + from: Claim + to: LogEntry + forEntity: Claim + """; + private static final String SAME_MODEL = """ name: sales entities: @@ -797,4 +835,190 @@ void rejectsATimestampPromptField() { .anyMatch(i -> i.contains("prompt field [allocatedAt] has type timestamp")), "got: " + ex.getIssues()); } + + /** + * The canonical append shape (issue #6800): a log row per completed step. The step IS the moment, + * so no {@code when} guard is needed, and the button is still dropped - nobody clicks a log. + */ + @Test + void aStepBoundAppendingCreateFromParses() { + IntentModel model = IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: { Claim: id, Amount: amount } + defaults: { Step: "activate" } + """); + GeneratesIntent g = model.getGenerates() + .get(0); + assertTrue(g.isEventDriven()); + assertTrue(g.isAppendMode()); + assertEquals(GeneratesIntent.MODE_APPEND, g.getEventMode()); + assertFalse(g.hasButton()); + } + + /** Absent {@code mode:} is the at-most-once cardinality every existing intent already has. */ + @Test + void theDefaultCardinalityIsOnce() { + IntentModel model = IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepReached: { process: ClaimApproval, step: review } } + map: { Claim: id } + """); + GeneratesIntent g = model.getGenerates() + .get(0); + assertEquals(GeneratesIntent.MODE_ONCE, g.getEventMode()); + assertFalse(g.isAppendMode()); + } + + /** A misspelled cardinality must not be read as the default - that would be a silent guard. */ + @Test + void rejectsAnUnknownEventMode() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: always } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("invalid mode [always]")), + "an unknown mode should be rejected, got: " + ex.getIssues()); + } + + /** A cardinality with nothing to apply it to: there is no guard on a button. */ + @Test + void rejectsAModeWithoutATrigger() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { mode: append } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("event requires")), + "a mode with no trigger should be rejected, got: " + ex.getIssues()); + } + + /** The back-reference is the row's provenance under append - still required. */ + @Test + void appendModeStillRequiresTheBackReference() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: { Amount: amount } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("provenance under mode: append")), + "a missing back-reference should be rejected in append mode too, got: " + ex.getIssues()); + } + + @Test + void rejectsAnUnknownProcessOrStep() { + IntentValidationException unknownProcess = + assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: Nope, step: activate } } + map: { Claim: id } + """)); + assertTrue(unknownProcess.getIssues() + .stream() + .anyMatch(i -> i.contains("unknown process [Nope]")), + "got: " + unknownProcess.getIssues()); + + IntentValidationException unknownStep = + assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: nope } } + map: { Claim: id } + """)); + assertTrue(unknownStep.getIssues() + .stream() + .anyMatch(i -> i.contains("unknown step [nope]")), + "got: " + unknownStep.getIssues()); + } + + /** An end (or a decision, or a wait) occupies no moment, so it has no boundary to emit at. */ + @Test + void rejectsAStepKindWithNoMomentToObserve() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: done } } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("only a userTask or a serviceTask has a moment to observe")), + "got: " + ex.getIssues()); + } + + /** + * The step event is about the record its process runs on - if that is not the create-from's source, + * the listener would read a record of the wrong entity by an id that means nothing to it. + */ + @Test + void rejectsAStepEventWhoseProcessRunsOnAnotherEntity() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepReached: { process: LogReview, step: check } } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("runs on [LogEntry], not on the from entity [Claim]")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsAStepEventNextToALifecycleTrigger() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onCreate: Claim, onStepCompleted: { process: ClaimApproval, step: activate } } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("exactly one trigger is allowed")), + "got: " + ex.getIssues()); + } + + /** A process and its steps belong to the model that declares them - a foreign source has none. */ + @Test + void rejectsAStepEventOnACrossModelSource() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(""" + name: claim-logs + uses: + - { model: claims } + entities: + - name: LogEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: step, type: string } + relations: + - { name: Claim, kind: manyToOne, to: Claim, model: claims } + processes: + - name: LogReview + trigger: { onCreate: LogEntry } + steps: + - { name: check, kind: userTask, args: { assignee: approver, next: over } } + - { name: over, kind: end } + generates: + - name: log-activation + from: Claim + fromUses: claims + to: LogEntry + forEntity: Claim + event: { onStepReached: { process: LogReview, step: check }, mode: append } + map: { Claim: id } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("cross-model source") && i.contains("local to the model that declares them")), + "got: " + ex.getIssues()); + } + + /** An appending create-from is still event-driven, so there is nobody to answer a prompt. */ + @Test + void rejectsAPromptOnAnAppendingCreateFrom() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_STEP_HEAD + """ + event: { onStepCompleted: { process: ClaimApproval, step: activate }, mode: append } + map: { Claim: id } + prompt: + - { field: Step, required: true } + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("prompt")), + "got: " + ex.getIssues()); + } + } 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 d9392925489..b0fca5ad170 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 @@ -555,8 +555,11 @@ private static void bindGenerate(Map item, Map c "itemLines", "fromItemEntity", "toItemEntity", "srcFkProperty", "toFkProperty", "itemFieldAssignments", "fromPerspective", "sourceStatusProperty", "sourceStatusValue", // The event half (issue #6711): the trigger kind, the status guard and the back-reference - // the at-most-once check reads, plus whether a button is contributed at all. - "fromPk", "eventOnly", "hasEvent", "isCreate", "guardProperty", "guardValue", "backRefProperty", + // the at-most-once check reads, plus whether a button is contributed at all. The axis and + // the cardinality (issue #6800): the topic suffix the listener binds - a lifecycle one or + // a step-scoped one - and whether the create-from keeps its at-most-once lookup at all. + "fromPk", "eventOnly", "hasEvent", "isCreate", "guardProperty", "guardValue", "backRefProperty", "isStep", "stepProcess", + "stepName", "topicSuffix", "appendMode", // 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"); 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 c2a5337f1c1..9f529908373 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 @@ -30,11 +30,20 @@ import org.eclipse.dirigible.sdk.http.Response; * single caller. #end #if($hasEvent) +#if($appendMode) + * + * Appends (intent `mode: append`): it is triggered by a ${fromEntity} event and keeps NO existing-target + * lookup, so every delivery creates another ${toEntity} - the "a row per event" shape (a log, a + * protocol). The ${backRefProperty} back-reference is still written on each row: it is that row's + * provenance, not a dedup key. This is the ABSENCE of a guard, so an event redelivery appends a + * duplicate; use `mode: once` for anything that must exist at most once. +#{else} * * At-most-once: it is triggered by a ${fromEntity} event, so it first looks for a ${toEntity} that * already back-references the source through ${backRefProperty} and returns that one instead of * creating a second document. 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(!$eventOnly) @@ -88,7 +97,7 @@ public class ${className}Generate { if (source == null) { return null; } -#if($hasEvent) +#if($hasEvent && !$appendMode) // Already generated for this source (an event redelivery, or a click after the event) - hand back // the existing document rather than minting a duplicate. Best-effort against two concurrent // deliveries until a UNIQUE key on the back-reference lands, exactly as postings' guard is. diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template index 2b23489c5ef..4196ef163bf 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateOnEvent.java.template @@ -6,28 +6,43 @@ import org.eclipse.dirigible.sdk.messaging.MessageHandler; import org.eclipse.dirigible.sdk.utils.Json; /** - * Event-driven create-from ${name}: when a ${fromEntity}#if($isCreate) is created#{else} transitions into status ${guardValue}#end, the + * Event-driven create-from ${name}: when a ${fromEntity}#if($isStep) reaches the ${stepName} step of the ${stepProcess} process#elseif($isCreate) is created#{else} transitions into status ${guardValue}#end, the * ${toEntity} is created from it - with no one clicking anything. * - * Generated from the intent generates block's `event:` (issue #6711) - do not edit; it is re-generated - * with the application. + * Generated from the intent generates block's `event:` (issues #6711, #6800) - do not edit; it is + * re-generated with the application. * * Contract: - * - binds the #if($isCreate)source's create topic (the platform publishes creates unsuffixed)#{else}source's -transitioned topic - the channel every status write publishes, - * which -updated reactions never see, so there are no loops#end; +#if($isStep) + * - binds the step-scoped topic the generated step emitter publishes the ${fromEntity} on at that + * step's boundary - published after commit and NOT transactional with the step, so a redelivery + * is possible; +#elseif($isCreate) + * - binds the source's create topic (the platform publishes creates unsuffixed); +#{else} + * - binds the source's -transitioned topic - the channel every status write publishes, + * which -updated reactions never see, so there are no loops; +#end * - RE-LOADS the source by id before the guard, because the event payload is as-of the event and lacks * later-step data such as a stamped document number; * - delegates to ${className}Generate, the SAME create-from a button would call, so the mapping, the * items and the sourceStatus completion hook cannot diverge between the two triggers; +#if($appendMode) + * - APPENDS (intent `mode: append`): the create-from carries no existing-target lookup, so every + * delivery of this event creates another ${toEntity}, each back-referencing its source through + * ${backRefProperty}. A redelivery therefore appends a duplicate row - by design; `mode: once` is + * the cardinality for anything that must exist at most once. +#{else} * - at-most-once through the ${backRefProperty} back-reference (the guard lives in that create-from): * a redelivery returns the document that already exists instead of minting a second one. +#end */ @Component("${javaGenFolderName}_${className}GenerateOnEvent") public class ${className}GenerateOnEvent implements MessageHandler { @Override public String destination() { - return "${fromProjectName}-${fromPerspective}-${fromEntity}#if(!$isCreate)-transitioned#end"; + return "${fromProjectName}-${fromPerspective}-${fromEntity}${topicSuffix}"; } @Override diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index fc4107c23c9..cead6372a5a 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -695,6 +695,27 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 100 } + # generates step axis + mode: append (#6800): a shipment whose flow is observed at two + # moments, a log the observations are APPENDED to (several rows per source - the + # cardinality the at-most-once create-from could not express), and a summary minted + # ONCE for the same source on the same axis. + - name: Shipment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + - name: ShipmentLog + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: step, type: string, length: 100 } + relations: + - { name: Shipment, kind: manyToOne, to: Shipment } + - name: ShipmentSummary + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + - { name: Shipment, kind: manyToOne, to: Shipment } + aggregates: - name: ledgerTotal of: Ledger @@ -763,6 +784,16 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: markReplied, kind: serviceTask, args: { setField: state, value: REPLIED, next: end } } - { name: end, kind: end } + # generates step axis (#6800): every step is a serviceTask, so both observed moments - + # dispatch REACHED and settle COMPLETED - fire without any inbox interaction. The two + # appending create-froms and the one at-most-once create-from below bind to them. + - name: ShipmentFlow + trigger: { onCreate: Shipment } + steps: + - { name: dispatch, kind: serviceTask, args: { setField: note, value: DISPATCHED, next: settle } } + - { name: settle, kind: serviceTask, args: { setField: note, value: SETTLED, next: end } } + - { name: end, kind: end } + # abortOn: voiding the approval (CancelApproval -> status 3) cancels the confirm task. - name: ApprovalFlow trigger: { onCreate: Approval } @@ -989,6 +1020,39 @@ class IntentEmissionCoverageIT extends IntegrationTest { - note: "Slip {label}" amount: "Total * 2" when: "Total != 0" + # generates on the step axis with mode: append (#6800). TWO appending rules share the + # target AND the back-reference on purpose: that is legal under append (each row records + # a different moment), where mode: once would make the second one a permanent no-op. + # `button: true` on the second keeps the click, which appends as well - append is the + # ABSENCE of a guard, not a state-aware one. + - name: log-dispatch + from: Shipment + to: ShipmentLog + event: { onStepReached: { process: ShipmentFlow, step: dispatch }, mode: append } + map: + Shipment: id + defaults: + step: "dispatch" + - name: log-settle + from: Shipment + to: ShipmentLog + event: { onStepCompleted: { process: ShipmentFlow, step: settle }, mode: append } + button: true + map: + Shipment: id + defaults: + step: "settle" + # the DEFAULT cardinality on the very same axis: one summary per shipment, and a click + # after the event hands back the one that exists instead of minting a second. + - name: summary-from-shipment + from: Shipment + to: ShipmentSummary + event: { onStepCompleted: { process: ShipmentFlow, step: settle } } + button: true + map: + Shipment: id + defaults: + note: "summary" # Prompted create-from (#6685): a per-record action that collects the input the source # cannot derive (here: a manual line's note + amount) before creating a composition # child. The prompted values are posted with the source id and set on the target after @@ -2185,6 +2249,27 @@ private void assertEmission() { assertTrue(generate.contains(".eq(\"Slip\", sourceId)") && generate.contains("return existing.get(0);"), "an event-driven create-from must return the document already back-referencing the source"); + // generates on the step axis + mode: append (#6800): the listener binds the step-scoped topic + // the generated emitter publishes the trigger entity on (NOT a lifecycle topic), and the + // appending create-from renders WITHOUT the existing-target lookup - while its at-most-once + // sibling on the same moment keeps it. The emitter itself exists because a create-from asked + // for that moment, with no notification or integration bound to it. + String logSettleOnEvent = contentOf("gen/events/emission/LogSettleGenerateOnEvent.java"); + assertTrue(logSettleOnEvent.contains("-Shipment-step-ShipmentFlow-settle-completed"), + "a step-bound create-from must bind the step-scoped topic of the trigger entity"); + assertTrue( + contentOf("gen/events/emission/LogDispatchGenerateOnEvent.java").contains("-Shipment-step-ShipmentFlow-dispatch-reached"), + "onStepReached must bind the reached moment's topic"); + String logSettleGenerate = contentOf("gen/events/emission/LogSettleGenerate.java"); + assertFalse(logSettleGenerate.contains(".eq(\"Shipment\", sourceId)"), + "mode: append must drop the existing-target lookup - every event appends a row"); + assertTrue(logSettleGenerate.contains("target.Step = \"settle\";"), + "the appended row must still be built by the whole create-from (map + defaults)"); + assertTrue(contentOf("gen/events/emission/SummaryFromShipmentGenerate.java").contains(".eq(\"Shipment\", sourceId)"), + "the default cardinality must keep the at-most-once lookup"); + assertTrue(contentOf("gen/events/emission/ShipmentFlowSettleCompleted.java").contains("implements JavaDelegate"), + "a create-from asking for a step moment must get that moment's emitter, even as its only consumer"); + // generates prompt (#6685): the prompted controller takes a values map, enforces the // required input with a 400, and converts each posted value to the target field's Java type // (decimal -> BigDecimal). The action descriptor carries the prompt so the customActions @@ -3323,6 +3408,101 @@ private void assertRuntimeEnforcement() { assertInboundSourcesRuntime(); assertOutboundDepartureRuntime(); assertBpmEventsRuntime(); + assertGeneratesStepAxisRuntime(); + } + + /** + * The step axis and both cardinalities of an event-driven create-from (#6800) at the outermost + * layer, on ONE shipment whose all-serviceTask flow runs by itself: the two appending create-froms + * bound to two moments of it append TWO log rows for that single source - sharing the target AND + * the back-reference, which is exactly what {@code mode: once} makes impossible - and clicking the + * kept button appends a THIRD, because append is the absence of a guard rather than a state-aware + * one. The at-most-once create-from bound to the SAME moment mints exactly one summary, and a click + * after the event hands back that one instead of a second. + */ + private void assertGeneratesStepAxisRuntime() { + AtomicInteger shipmentId = new AtomicInteger(); + restAssuredExecutor.execute(() -> shipmentId.set(given().contentType("application/json") + .body("{\"Note\":\"crate 7\"}") + .when() + .post(API + "/shipment/ShipmentController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + + // Both moments of the process fired and each appended its own row - the step topic reached a + // create-from, which no construct could do before, and the second rule was NOT swallowed by the + // first one's back-reference. + restAssuredExecutor.execute(() -> { + String rows = "findAll { it.Shipment == " + shipmentId.get() + " }"; + io.restassured.path.json.JsonPath logs = given().when() + .get(API + "/shipmentlog/ShipmentLogController") + .then() + .statusCode(200) + .extract() + .jsonPath(); + assertEquals(2, logs.getList(rows) + .size(), + "each observed step moment must append its own log row"); + assertEquals(List.of("dispatch", "settle"), logs.getList(rows + ".Step") + .stream() + .map(String::valueOf) + .sorted() + .toList(), + "the appended rows must record which moment each of them is about"); + }, 60); + + // The at-most-once sibling on the same moment: exactly one summary. + AtomicInteger summaryId = new AtomicInteger(); + restAssuredExecutor.execute(() -> { + String rows = "findAll { it.Shipment == " + shipmentId.get() + " }"; + io.restassured.path.json.JsonPath summaries = given().when() + .get(API + "/shipmentsummary/ShipmentSummaryController") + .then() + .statusCode(200) + .extract() + .jsonPath(); + assertEquals(1, summaries.getList(rows) + .size(), + "the default cardinality must mint exactly one summary per source"); + summaryId.set(summaries.getInt(rows + ".Id[0]")); + }, 60); + + // A click on the appending create-from's kept button appends a THIRD row... + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + shipmentId.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/emission/LogSettleGenerate/run") + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> { + String rows = "findAll { it.Shipment == " + shipmentId.get() + " }"; + assertEquals(3, given().when() + .get(API + "/shipmentlog/ShipmentLogController") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList(rows) + .size(), + "append carries no guard at all - a click after the events appends another row"); + }); + + // ...while the same click on the at-most-once sibling hands back the summary that exists. + restAssuredExecutor.execute(() -> { + String summary = given().contentType("application/json") + .body("{\"id\":" + shipmentId.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/emission/SummaryFromShipmentGenerate/run") + .then() + .statusCode(200) + .extract() + .asString(); + assertEquals(summaryId.get(), io.restassured.path.json.JsonPath.from(summary) + .getInt("Id"), + "mode: once must still return the existing target on a second delivery"); + }); } /** From c9e2bfd3d45b1ad6b33d10eddd969919caefef95 Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 19 Aug 2026 14:31:43 +0300 Subject: [PATCH 2/2] fix(templates): a create-from glue written before the step axis keeps its topic The step axis (#6800) moved the event topic's suffix out of the GenerateOnEvent template - which derived it from `isCreate` - and into the glue, where the intent generator states it once for every axis. A `.glue` written before that carries no suffix at all, and Velocity renders an absent reference as its own literal, so the listener bound `...-SalesOrder${topicSuffix}` - a topic nothing publishes on, and a create-from that silently never fires. The binder now defaults the missing key to the lifecycle suffix that shape implied (none for a create, -transitioned otherwise), so the template keeps emitting the glue's value verbatim and there is still one writer of the topic. The generation fixture keeps its pre-step entry as the regression guard and gains a step-bound, append-mode one, which covers the branches the new template grew. Co-Authored-By: Claude Opus 5 --- .../template/service/model/GlueGenerator.java | 7 ++- .../resources/ModelGenerationIT/orders.glue | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) 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 b0fca5ad170..5e9ac2ae2e4 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 @@ -559,10 +559,15 @@ private static void bindGenerate(Map item, Map c // the cardinality (issue #6800): the topic suffix the listener binds - a lifecycle one or // a step-scoped one - and whether the create-from keeps its at-most-once lookup at all. "fromPk", "eventOnly", "hasEvent", "isCreate", "guardProperty", "guardValue", "backRefProperty", "isStep", "stepProcess", - "stepName", "topicSuffix", "appendMode", + "stepName", "appendMode", // 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"); + // 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 + // one is the lifecycle suffix that shape implied: none for a create, -transitioned otherwise. + context.put("topicSuffix", strOr(item, "topicSuffix", truthy(item, "isCreate") ? "" : "-transitioned")); context.put("fromJavaPerspective", sanitize(item, "fromPerspective")); // The SOURCE's gen folder / owning project: this project unless the source belongs to another // model (intent `fromUses:`). That is what lets a create-from be authored on the module owning diff --git a/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue b/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue index 3307aa2a008..efce83fdcbc 100644 --- a/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue +++ b/tests/tests-integrations/src/main/resources/ModelGenerationIT/orders.glue @@ -239,6 +239,37 @@ "guardProperty": "Status", "guardValue": "3", "backRefProperty": "SalesOrder" + }, + { + "name": "pickListFromOrder", + "className": "PickListFromOrder", + "crossModel": false, + "fromEntity": "SalesOrder", + "fromPerspective": "Sales Orders", + "fromPk": "Id", + "crossModelSource": false, + "fromModel": "", + "fromProject": "", + "toEntity": "PickList", + "toModel": "", + "toPerspective": "Deliveries", + "toPk": "Id", + "fieldAssignments": " target.SalesOrder = source.Id;\n", + "sourceStatusProperty": "", + "sourceStatusValue": "", + "hasItems": false, + "hasItemLines": false, + "eventOnly": true, + "hasEvent": true, + "isCreate": false, + "isStep": true, + "stepProcess": "OrderApproval", + "stepName": "warehousePick", + "topicSuffix": "-step-OrderApproval-warehousePick-reached", + "appendMode": true, + "guardProperty": "", + "guardValue": "", + "backRefProperty": "SalesOrder" } ], "generateEvents": [ @@ -267,6 +298,37 @@ "guardProperty": "Status", "guardValue": "3", "backRefProperty": "SalesOrder" + }, + { + "name": "pickListFromOrder", + "className": "PickListFromOrder", + "crossModel": false, + "fromEntity": "SalesOrder", + "fromPerspective": "Sales Orders", + "fromPk": "Id", + "crossModelSource": false, + "fromModel": "", + "fromProject": "", + "toEntity": "PickList", + "toModel": "", + "toPerspective": "Deliveries", + "toPk": "Id", + "fieldAssignments": " target.SalesOrder = source.Id;\n", + "sourceStatusProperty": "", + "sourceStatusValue": "", + "hasItems": false, + "hasItemLines": false, + "eventOnly": true, + "hasEvent": true, + "isCreate": false, + "isStep": true, + "stepProcess": "OrderApproval", + "stepName": "warehousePick", + "topicSuffix": "-step-OrderApproval-warehousePick-reached", + "appendMode": true, + "guardProperty": "", + "guardValue": "", + "backRefProperty": "SalesOrder" } ] }