diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 39e73d7967c..4e0af648096 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -410,7 +410,7 @@ Semantics worth knowing: - **The notify fan-out addresses TWO records, and says which one it means (`attach: recordPrint` + the `record.` scope, #6717).** `notify.forEach` sends one message per row of a related entity, and until now everything in the block resolved against the ROW - right for the payslip case (one document per row), impossible for its mirror: **one** document to **many** recipients, where the rows are only the recipient list (a request for quotation mailed to each invited supplier, an agenda to each participant) and the document belongs to the record they hang off. **`attach: recordPrint`** renders the fan-out's **anchor record** instead of the row - fan-out-only (without one, `attach: print` already renders that record), the ANCHOR is what must be a printable document, and `language:`/`languageFrom:` then read off it, because the render happens **ONCE**: `NotifySupport.printAttachment` pre-renders every expression against the `source` local (`RECORD_LOCAL`) instead of the per-row `entity`, and both fan-out templates (`Send`, `Transition`) call a `renderDocument(source)` before the loop - and not at all when there are no rows, so an empty recipient list costs no render and cannot fail a step for nobody. **Placeholders are explicitly scoped:** a bare path keeps resolving against the ROW and the reserved prefix **`record.`** reaches the anchor (`{record.number}`, ONE field of it - a walk on would need a second load per message and belongs in a field of the record). The recipient may never be record-scoped (the rows ARE the recipients, so it would mail one address once per row), and `record.` outside a fan-out is an error (there the bare placeholder already IS the record's) - implicit mixing is how a message quotes the wrong party with nothing in the output to show it. The generated per-row send method takes the anchor only when a message actually quotes it (the `notifyRecordScoped` glue key). **Alongside it, a `forEach` at a call site that cannot generate one is now REJECTED** - a `schedules[].notify` already runs per matched row and a `notifications[]` entry is about the event record, so both parsed it and silently sent the single per-record message instead (the authored-but-unconsumed class). Covered by `GlueSendDocumentTest` (scoping + the six rejections) and `IntentEmissionCoverageIT` (the `BillFlow` `shareBill` step: one render call, `renderDocument(source)` fed with the anchor's key, the row-scoped recipient, the record-scoped subject). - **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), 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` takes a seed **id** only - it is not one of the sites `StatusSymbolResolver` rewrites, so a seeded name would not reach the graph.) 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`. +- **`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. - **`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). - **A `map:` source may be a one-hop `relation.field` (the SNAPSHOT copy).** `map:` could name only a property of the source ROW, so a value one relation out was inexpressible - and those are exactly the values an audit log needs ("record the plate number the check was made against", where the plate lives on Vehicle and the log is written from Fine). The workaround was not equivalent: holding the relation and displaying through it reads the CURRENT value, so correcting a plate typo silently rewrites every past log row, whereas a map copies what was true when the row was written - which is what an append-only log means by a value. The other route was `calculatedOnCreate`, i.e. hand-written Java for one field. **The mechanism is not new**: `NotificationSupport.Resolver.access()` has always turned a one-hop path into a `RelationLoad` plus a null-guarded `(rel == null ? null : rel.Field)` read, deduped per relation and cross-model aware, and seven templates already render `$relationLoads` from it - `Generate.java.template` simply was not one of them. So the change is three edits and no new machinery: `validateMapSource` accepts the hop (modelled on `validateBalanceDate`, the same split), `assignments()` gained an overload that delegates a dotted value to that resolver (a direct property keeps its `.` concatenation - `source.Vehicle` is an FK integer, which is precisely why walking it never could work), and both create-from templates emit the load before the mapping reads it (`Generate.java.template` AFTER the at-most-once guard, so an already-generated source costs no query; `Job.java.template` once per queried row). **What is refused, with the reason:** a second hop (the value two relations out belongs in a field of the entity in between); a tail that is itself a relation (it would copy a key out of another entity's numbering space into a column whose relation points elsewhere - no type error would catch it, both are integers); an `items` map (its source is the row being cloned, so the load would run per row inside the clone loop); and a hop off a `fromUses:` source, which is a dropped-glue report rather than a parse error because only the owner's `.model` knows that source's relations. A hop off a cross-model RELATION works - the resolver reads the owner's facts, as it does for a notification. No type checking between the two ends, exactly as a direct `map:` has none. Covered by `GlueMapHopTest` (the rendered read, one load per relation for two mapped fields, the schedule axis, and each rejection) + `ModelGenerationIT`'s glue fixture, whose create-from now carries a same-model AND a cross-model load so the template block actually renders - the unrendered-template gap that let three earlier branches ship a broken events template. diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index 88acabb5cf6..f7754fa6d84 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -404,6 +404,7 @@ generates: defaults: { InvoiceDate: now } items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } } sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus after creation + sourceStatusOnRetire: 2 # optional INVERSE: where the SOURCE returns when the target is retired ``` `items:` has two mutually-exclusive shapes. As an OBJECT (above) it MIRRORS each source child row @@ -426,6 +427,18 @@ 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. +`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 +source may be generated from again. `sourceStatusOnRetire:` is the INVERSE of the completion hook and +what makes that reissue automatic: retiring the target returns the source to the named status - one +targeted write carrying the source's `-transitioned` - so the ordinary trigger re-fires and mints the +replacement. It fires only while the source still stands at `sourceStatus` and no target of it still +counts, so a redelivered retirement is a no-op. Without it, a source flipped by `sourceStatus:` can never re-qualify and only a shared +`button: true` can reissue. It needs an `event:` to re-fire, `sourceStatus:` (a different +status), a local target whose nomenclature classifies a retiring stage, `mode: once`, and - when the +source declares a `lifecycle:` - the edge back. + `prompt:` (#6685) declares a small input form shown before the target is created - the values the source cannot derive (which payment, how much). Entries name fields / to-one relations of the TARGET, so the dialog's controls are typed from the target's own definitions and its `dependsOn:` 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 cf87bf44b69..d24ca0a5c78 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 @@ -170,6 +170,13 @@ public void generate(IntentGenerationContext context) { glue.put("generateEvents", generates.stream() .filter(entry -> Boolean.TRUE.equals(entry.get("hasEvent"))) .toList()); + // The declared-reopen subset (issue #6868), filtered from the same descriptors for the same + // reason: the listener that returns the source when its target is retired must agree with the + // create-from's own guard about what "retired" means, and a create-from that declares no reopen + // must contribute no listener at all. + glue.put("generateReopens", generates.stream() + .filter(entry -> Boolean.TRUE.equals(entry.get("hasReopen"))) + .toList()); glue.put("transitions", transitions); glue.put("sends", sends); glue.put("postings", postings); @@ -714,7 +721,9 @@ private static List> buildSettlementListeners(List * An entry declaring an {@code event} (issue #6711) additionally lands in the * {@code generateEvents} collection, whose template renders the listener that calls the same - * create-from - see {@link #putGeneratesEvent}. + * create-from - see {@link #putGeneratesEvent}. One declaring a {@code sourceStatusOnRetire} (issue + * #6868) lands in {@code generateReopens} as well, whose template renders the listener that returns + * the source when the target it made is retired - see {@link #putSupersededTarget}. */ private static List> buildGenerates(IntentModel model, Map byName, Map compositionParents, IntentSettings settings, IntentGenerationContext context) { @@ -835,6 +844,15 @@ private static List> buildGenerates(IntentModel model, Map e, * carry a lifecycle whose nomenclature nobody classified gets the warning - that combination is the * silent one, where the guard looks state-aware and is not. * + *

+ * The SAME resolution also drives the declared reopen (issue #6868), which reads the classification + * from the other end: the guard asks whether the target that already exists is retired, the reopen + * listener asks whether the transition it just saw is what retired it. Emitting both from one + * resolution is what stops them disagreeing about what "retired" means - and the reason the reopen + * introduces no vocabulary of its own to say it. + * * @param g the create-from * @param e the glue entry being built * @param model the model being generated @@ -1047,9 +1072,13 @@ private static void putSupersededTarget(GeneratesIntent g, Map e e.put("hasRetiredStatus", false); e.put("retiredStatusProperty", ""); e.put("retiredStatusCondition", ""); + e.put("hasReopen", false); + e.put("reopenStatusValue", ""); + e.put("reopenRetiredCondition", ""); // An appending create-from (issue #6800) keeps no guard at all, so there is nothing for a // retired target to release - and warning about an unclassified nomenclature there would be - // noise about a guard that does not exist. + // noise about a guard that does not exist. A reopen is refused on that shape by the parser, so + // there is nothing to emit for it here either. if (!g.isEventDriven() || g.isAppendMode()) { return; } @@ -1077,17 +1106,41 @@ private static void putSupersededTarget(GeneratesIntent g, Map e e.put("retiredStatusProperty", property); // Rendered against the template's loop variable: a retired candidate is stepped over, the first // one that is not is this source's document. + e.put("retiredStatusCondition", retiredCondition("candidate", property, retired)); + // The declared reopen (issue #6868) reads the SAME classification from the other end: the guard + // asks "is the document that exists retired?", the reopen listener asks "did this transition + // retire it?". One resolution, so the two can never disagree about what retired means - which + // is the whole reason the reopen adds no vocabulary of its own for it. + if (!g.hasReopen()) { + return; + } + e.put("hasReopen", true); + e.put("reopenStatusValue", String.valueOf(g.getSourceStatusOnRetire())); + e.put("reopenRetiredCondition", retiredCondition("target", property, retired)); + } + + /** + * The retiring-status test as a Java disjunction over a named local - {@code cancelled} and + * {@code void} ids in seed order. + * + * @param local the Java local the status is read off + * @param property the status FK property + * @param retired the retiring seed ids + * @return the rendered condition + */ + private static String retiredCondition(String local, String property, List retired) { StringBuilder condition = new StringBuilder(); for (Integer id : retired) { if (condition.length() > 0) { condition.append(" || "); } - condition.append("candidate.") + condition.append(local) + .append('.') .append(property) .append(" == ") .append(id); } - e.put("retiredStatusCondition", condition.toString()); + return condition.toString(); } /** 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 1e89143d39b..2cbcb1a7ab6 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 @@ -158,6 +158,36 @@ public class GeneratesIntent { */ private Integer sourceStatus; + /** + * The INVERSE of {@link #sourceStatus} (issue #6868): the seed id the SOURCE returns to when the + * target generated from it is RETIRED - reaches a status its nomenclature classifies {@code + * cancelled} or {@code void}. Void and reissue, declared. + * + *

+ * Why it is needed at all: {@link #sourceStatus} moves the source off the status its own + * {@code event} guard qualifies on, deliberately, so the guard-claimed source stops matching. The + * at-most-once guard learned to step over a retired target (issue #6814), which frees the source's + * one-shot slot - but nothing could refill it: the source stands at its post-generation status and + * the ordinary lifecycle graph declares no edge back, so no qualifying {@code -transitioned} is + * ever published again and an event-only create-from had no reissue path at all. + * + *

+ * This declares the move back. The retirement of the target flips the source to this status through + * the same targeted primitive the completion hook uses, publishing the source's + * {@code -transitioned} with the write - so the ordinary trigger re-fires, the guard steps over the + * retired document, and the replacement is minted. Nothing about the reissue is a special path: it + * is the source's own lifecycle move plus the machinery that was already there. The retired + * document is kept, never edited or re-pointed. + * + *

+ * It is opt-in and refused where it could never fire (see + * {@code IntentParser.validateGeneratesReopen}): it requires an {@link #event} to re-fire and + * {@link #sourceStatus} to invert, must name a status other than that one, needs a LOCAL target + * whose nomenclature classifies a retiring {@code stage:}, and - when the source declares a + * {@code lifecycle:} - needs that graph to declare the edge back. + */ + private Integer sourceStatusOnRetire; + /** Target property -> source property (a field or to-one relation name of {@link #from}). */ private Map map = new LinkedHashMap<>(); @@ -337,6 +367,22 @@ public void setSourceStatus(Integer sourceStatus) { this.sourceStatus = sourceStatus; } + public Integer getSourceStatusOnRetire() { + return sourceStatusOnRetire; + } + + public void setSourceStatusOnRetire(Integer sourceStatusOnRetire) { + this.sourceStatusOnRetire = sourceStatusOnRetire; + } + + /** + * Whether a retired target returns the source to a status of its own (see + * {@link #sourceStatusOnRetire}). + */ + public boolean hasReopen() { + return sourceStatusOnRetire != null; + } + public Map getMap() { return map; } 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 366a7753e77..95dfe81b573 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 @@ -5623,6 +5623,96 @@ private static void validateGenerates(IntentModel model, Set entityNames } validateGeneratesItemLines(g, name, source, byName, crossModel, issues); validateGeneratesPrompt(g, name, byName, crossModel, issues); + validateGeneratesReopen(g, name, byName, crossModel, model, issues); + } + } + + /** + * 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 + * target generated from it is retired, which is what makes "void and reissue" reachable without a + * click. + * + *

+ * Everything here refuses a combination in which the reopen could never fire, because a reopen that + * cannot fire is exactly the silence this feature exists to remove: the at-most-once guard steps + * over a retired target (issue #6814) and frees the source's slot, and if nothing can refill it the + * author is left with a model that reads as automatic and is not. So the hook must exist to be + * inverted, the inverse must be a different status, the target must be one whose retirement is + * recognisable HERE (a local target whose nomenclature classifies a retiring {@code stage:}), and + * an appending create-from - which keeps no guard and no slot - is refused outright. + * + *

+ * The remaining check is the source's own state machine, and it lives with the other status writes + * in {@link #validateStatusWritesAgainstLifecycle}: the source stands at {@code sourceStatus} when + * the retirement arrives, so the graph must declare that exact edge back. + */ + private static void validateGeneratesReopen(GeneratesIntent g, String name, Map byName, boolean crossModel, + IntentModel model, List issues) { + if (!g.hasReopen()) { + return; + } + String subject = "generates [" + name + "]"; + if (g.getSourceStatus() == null) { + issues.add(subject + " declares sourceStatusOnRetire but no sourceStatus - the reopen is the INVERSE of the completion" + + " hook, and with no flip forward the source never leaves the status its own trigger qualifies on, so there is" + + " nothing to return it from"); + return; + } + if (g.getSourceStatusOnRetire() + .equals(g.getSourceStatus())) { + issues.add(subject + " returns the source to [" + g.getSourceStatus() + + "], the very status sourceStatus flips it to - a write that leaves the status where it stands is no transition," + + " so nothing would be published and nothing would re-fire; name the status the source qualified on before the" + + " target existed"); + return; + } + if (g.isAppendMode()) { + issues.add(subject + " declares sourceStatusOnRetire with mode: append - an appending create-from keeps no at-most-once" + + " guard, so no slot is ever consumed for a retired target to free, and returning the source would simply append" + + " another " + g.getTo() + "; drop the reopen, or use mode: once"); + return; + } + if (!g.isEventDriven()) { + // A create-from with no event carries no guard at all, so nothing ever blocks a second + // creation: the button IS the reissue. There is no slot to free and no trigger to re-fire, + // which is why the glue emits no reopen listener for this shape - and an authored key that + // generates nothing is the silence this whole construct exists to refuse. + issues.add(subject + " declares sourceStatusOnRetire but has no event: - a create-from triggered only by a button carries" + + " no at-most-once guard, so nothing blocks a replacement and the button already reissues. The reopen exists to" + + " re-fire an EVENT trigger; declare event: or drop the key"); + return; + } + if (crossModel) { + issues.add(subject + " cannot reopen for a cross-model target (uses [" + g.getUses() + "]) - what RETIRES a [" + g.getTo() + + "] is the `stage:` classification of its status nomenclature, seeded in the owner model and not resolvable here;" + + " author the create-from in [" + g.getUses() + "], or keep a button (button: true) to reissue by hand"); + return; + } + EntityIntent target = g.getTo() == null ? null : byName.get(g.getTo()); + if (target == null) { + return; // an unknown target is already reported + } + RelationIntent status = LifecycleStages.statusRelation(target); + if (status == null || status.getTo() == null) { + issues.add(subject + " declares sourceStatusOnRetire but its target [" + g.getTo() + + "] declares no function: EntityStatus relation - it can never be retired, so the reopen could never fire"); + return; + } + if (status.isCrossModel()) { + issues.add(subject + " target [" + g.getTo() + "] takes its lifecycle from [" + status.getModel() + ":" + status.getTo() + + "], a nomenclature seeded in another model, so no `stage:` classification is resolvable here - the retirement" + + " that would trigger the reopen cannot be recognised"); + return; + } + Map> stages = LifecycleStages.stagesOf(model, status.getTo()); + if (stages.getOrDefault(LifecycleStages.CANCELLED, List.of()) + .isEmpty() + && stages.getOrDefault(LifecycleStages.VOID, List.of()) + .isEmpty()) { + issues.add(subject + " declares sourceStatusOnRetire but no seed row of [" + status.getTo() + + "] is classified `stage: cancelled` or `stage: void` - that classification is what makes a [" + g.getTo() + + "] retired, so classify the seed rows of [" + status.getTo() + "] with `stage:` (draft/live/cancelled/void)"); } } @@ -5985,7 +6075,7 @@ private static void validateLifecycles(IntentModel model, List issues) { reachable.addAll(targets); } validateTransitionsAgainstLifecycle(model, entity, edges, statuses, issues); - validateStatusWritesAgainstLifecycle(model, entity, status, reachable, statuses, issues); + validateStatusWritesAgainstLifecycle(model, entity, status, edges, reachable, statuses, issues); } } @@ -6076,9 +6166,14 @@ private static void validateTransitionsAgainstLifecycle(IntentModel model, Entit * repository at run time. Checking them here is what turns an unmodeled move from a runtime * {@code ValidationException} into a message the author reads - and for {@code sourceStatus} that * matters twice over, because its flip runs AFTER the target document has already been committed. + * + *

+ * One of them CAN be pinned to an exact edge: a create-from's {@code sourceStatusOnRetire} (issue + * #6868) runs while the source stands at the {@code sourceStatus} the same rule flipped it to, so + * the graph is asked for that one edge rather than for reachability. */ private static void validateStatusWritesAgainstLifecycle(IntentModel model, EntityIntent entity, RelationIntent status, - Set reachable, Map statuses, List issues) { + Map> edges, Set reachable, Map statuses, List issues) { for (ProcessIntent process : model.getProcesses()) { if (!entity.getName() .equals(triggerEntityName(process))) { @@ -6111,6 +6206,18 @@ private static void validateStatusWritesAgainstLifecycle(IntentModel model, Enti + "] lifecycle reaches - add the edge or set a status the graph can enter (the flip runs AFTER the target" + " document is created, so a rejected one leaves the document behind)"); } + Integer reopened = generates.getSourceStatusOnRetire(); + if (flipped == null || reopened == null || reopened.equals(flipped)) { + continue; // the reopen's own validation owns both of those + } + if (!edges.getOrDefault(flipped, Set.of()) + .contains(reopened)) { + issues.add("generates [" + generates.getName() + "] returns the source to [" + statusLabel(reopened, statuses) + + "] when its target is retired, but the [" + entity.getName() + "] lifecycle declares no edge from [" + + statusLabel(flipped, statuses) + "] to it - that is exactly where the source stands when the retirement" + + " arrives, so the reopen would be rejected the moment it ran; add the edge, or return to a status [" + + statusLabel(flipped, statuses) + "] reaches"); + } } for (ResolveIntent resolve : model.getResolves()) { if (!entity.getName() 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 cc8969837fa..2c6d7a3a3c9 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 @@ -242,20 +242,27 @@ private void rewritePostings(Map root) { } /** - * An event-driven create-from (issue #6711) guards on the SOURCE's status exactly as a posting - * does; the source is {@code from:}, owned by {@code fromUses:} when it is not local. + * 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. */ private void rewriteGenerates(Map root) { for (Object node : asList(root.get("generates"))) { Map generate = asMap(node); - Map event = asMap(generate == null ? null : generate.get("event")); - if (event == null || event.get("when") == null) { + if (generate == null) { continue; } String source = text(generate, "from"); - String subject = "generates [" + text(generate, "name") + "] event when"; + String subject = "generates [" + text(generate, "name") + "]"; Target status = text(generate, "fromUses") != null ? new Target(source, text(generate, "fromUses")) : statusOf(source); - put(event, "when", rewriteExpression(text(event, "when"), statusRelationName(source), status, subject)); + Map event = asMap(generate.get("event")); + if (event != null && event.get("when") != null) { + put(event, "when", rewriteExpression(text(event, "when"), statusRelationName(source), status, subject + " event when")); + } + 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 08fcb54cb73..8c4c65a174d 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 @@ -1409,6 +1409,8 @@ generates: Amount: Amount 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 + # target is retired (cancelled/void) - see "void and reissue" ``` **A `map:` source may hop one relation - and that is how you SNAPSHOT a value.** A value is `map`ped @@ -1611,13 +1613,51 @@ generates: - **`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`. + answer to "I voided the document and cannot regenerate it": that is what the retiring-`stage:` + guard below does, 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 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). +- **A RETIRED target stops blocking - and `sourceStatusOnRetire:` is what lets the replacement be minted + without a click.** The at-most-once guard reads the target's `stage:` classification: a target whose + status is classified `cancelled` or `void` is retired, so the guard steps over it and the source may + be generated from again - void and reissue. A `draft` or `live` target still blocks, so redelivery + idempotence is untouched, and the retired document is kept, never edited or re-pointed. That frees the + slot; but where `sourceStatus:` is declared nothing could refill it. The completion hook moved the + source OFF the status its own trigger qualifies on - deliberately - and the ordinary lifecycle graph + declares no edge back, so no qualifying event is ever published again: an event-only create-from had + no reissue path at all, and only a shared `button: true` could raise the replacement. + `sourceStatusOnRetire:` declares the move back, so the reissue becomes the ORDINARY path: + + ```yaml + generates: + - name: invoice-from-proforma + from: Proforma + to: Invoice + event: { onTransition: Proforma, when: "Status == APPROVED" } + map: { Proforma: id } + sourceStatus: INVOICED # forward: the proforma is done once the invoice exists + sourceStatusOnRetire: APPROVED # back: voiding the invoice returns it - and the trigger re-fires + ``` + + Retiring the invoice returns the proforma to APPROVED through one targeted status write that carries + the source's `-transitioned` with it; the trigger re-fires, the guard steps over the retired invoice, + and the replacement is minted. It acts only while the source still stands where this rule's own hook + left it AND no target of that source still counts - the create-from's own guard asked from the other + end, which is what makes it idempotent with no marker column: a redelivered retirement arriving after + the replacement exists finds a live target and does nothing. + + It requires an `event:` to re-fire (a button-only create-from carries no guard, so nothing blocks a + replacement - the button already reissues) and `sourceStatus:` to invert, and must name a DIFFERENT + status; the target must be local, with a nomenclature that classifies a retiring `stage:` (a + cross-model target is seeded in its owner model, so nothing here can recognise its retirement - keep + `button: true` and reissue by hand); `mode: append` is refused (no guard, so no slot to free); and + when the source declares a `lifecycle:`, the graph must declare the edge from `sourceStatus` back to + it - that is exactly where the source stands when the retirement arrives. Return to a status a PERSON must move on (a `DRAFT` + for correction) when the reissue should be reviewed rather than immediate: the reopen only publishes + the transition, and the trigger's own `when:` decides whether anything fires. - Use this over `posts` when the result is a **document with line items**: `posts` writes flat mapped rows and cannot reference the freshly created header. Use it over a `generates` button plus a `wait` step when the step would be waiting for a human to remember to click - an unclicked record parks its @@ -2360,6 +2400,7 @@ so before binding a reaction, check what the thing you care about publishes. | `setField` / `setRelationField` on a step | `-transitioned` | `postings:`, `generates` `event: { onTransition }`, `abortOn:` | | A `transitions:` button (void / cancel / reopen) | `-transitioned` | the same three | | `generates` `sourceStatus:` flipping the source | `-transitioned` | the same three | +| `generates` `sourceStatusOnRetire:` returning the source | `-transitioned` | the same three (this is how the reissue re-fires) | | A `userTask` / `serviceTask` being reached or completed | a per-step topic | `onStepReached` / `onStepCompleted` | **Deliberately silent, and correct** - each of these would re-trigger its own handler if it published: 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 20f7408ade9..1aaa2f667bc 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 @@ -218,6 +218,29 @@ class GlueGeneratesTest { - { id: 4, name: VOIDED, stage: void } """; + /** + * The same retiring model with the reopen declared (issue #6868): the fine flips to DECLARED once + * the declaration exists, and returns to POSTED - the status its own trigger qualifies on - the + * moment that declaration is cancelled or voided. + */ + private static final String REOPEN_YAML = RETIRING_YAML.replace(""" + - { id: 2, name: POSTED } + """, """ + - { id: 2, name: POSTED } + - { id: 3, name: DECLARED } + """) + .replace(""" + map: + Fine: id + Note: note + """, """ + map: + Fine: id + Note: note + sourceStatus: 3 + sourceStatusOnRetire: 2 + """); + @SuppressWarnings("unchecked") @Test void rendersHeaderAssignmentsItemsAndKeys() { @@ -983,4 +1006,43 @@ void anAppendingGenerateNeitherRetiresNorWarns() { .isEmpty(), "an appending create-from must not be warned about: " + context.getIssues()); } + + /** + * The SAME classification read from the other end (issue #6868): the guard asks whether the + * document that exists is retired, the declared reopen asks whether the transition it just saw is + * what retired it - so the two can never disagree about what "retired" means, and the reopen adds + * no vocabulary of its own to say it. What it does add is the status the SOURCE returns to, which + * is what lets the ordinary trigger re-fire and mint the replacement. + */ + @Test + void aDeclaredReopenEmitsTheInverseOfTheCompletionHook() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(REOPEN_YAML)) + .get(0); + + // The completion hook forward... + assertEquals("Status", g.get("sourceStatusProperty")); + assertEquals("3", g.get("sourceStatusValue")); + // ...and its inverse, fired by the target's retirement. + assertEquals(true, g.get("hasReopen")); + assertEquals("2", g.get("reopenStatusValue")); + // The retiring test rendered against the reopen listener's own local - the same ids, in seed + // order, as the guard's `candidate` form. + assertEquals("target.State == 3 || target.State == 4", g.get("reopenRetiredCondition")); + assertEquals("candidate.State == 3 || candidate.State == 4", g.get("retiredStatusCondition")); + } + + /** + * The key is opt-in: the same model without it keeps exactly the descriptor it had, so a + * create-from written before the key existed regenerates byte-identical output and contributes no + * listener. + */ + @Test + void withoutTheKeyNoReopenIsEmitted() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(RETIRING_YAML)) + .get(0); + + assertEquals(false, g.get("hasReopen")); + assertEquals("", g.get("reopenStatusValue")); + assertEquals("", g.get("reopenRetiredCondition")); + } } 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 aaad05edb65..62567df61d0 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,59 @@ class GeneratesIntentTest { forEntity: Fine """; + /** + * The void-and-reissue shape of issue #6868: the source carries a lifecycle, and so does the target + * - with its statuses CLASSIFIED, since what retires a document is the {@code stage:} + * classification and nothing else. Ends at the {@code generates} entry's own keys, as the heads + * above do. + */ + private static final String GENERATES_REOPEN_HEAD = """ + name: fines + entities: + - name: FineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: DeclarationState + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + - name: Declaration + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Fine, kind: manyToOne, to: Fine } + - { name: State, kind: manyToOne, to: DeclarationState, function: EntityStatus, init: 1 } + seeds: + - name: fine-statuses + entity: FineStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: IDENTIFIED } + - { id: 3, name: DECLARED } + - name: declaration-states + entity: DeclarationState + rows: + - { id: 1, name: NEW, stage: draft } + - { id: 2, name: FILED, stage: live } + - { id: 3, name: CANCELLED, stage: cancelled } + - { id: 4, name: VOIDED, stage: void } + generates: + - name: declaration-from-fine + from: Fine + to: Declaration + 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. @@ -1027,4 +1080,259 @@ void rejectsAPromptOnAnAppendingCreateFrom() { "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 + * statuses are named, not numbered - the resolver turns them into seed ids before the typed + * mapping. + */ + @Test + void aDeclaredReopenParses() { + IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """); + GeneratesIntent g = model.getGenerates() + .get(0); + assertEquals(3, g.getSourceStatus()); + assertEquals(2, g.getSourceStatusOnRetire()); + assertTrue(g.hasReopen()); + } + + /** A create-from that declares no reopen is unchanged - the key is opt-in. */ + @Test + void withoutTheKeyThereIsNoReopen() { + IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + """); + GeneratesIntent g = model.getGenerates() + .get(0); + assertFalse(g.hasReopen()); + assertEquals(null, g.getSourceStatusOnRetire()); + } + + /** + * The reopen is the INVERSE of the completion hook, so without the hook there is nothing to invert: + * the source never left the status its trigger qualifies on. + */ + @Test + void rejectsAReopenWithoutACompletionHook() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatusOnRetire: IDENTIFIED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("sourceStatusOnRetire") && i.contains("no sourceStatus")), + "got: " + ex.getIssues()); + } + + /** A write that leaves the status where it stands is no transition, so nothing would re-fire. */ + @Test + void rejectsAReopenToTheCompletionStatus() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: DECLARED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("the very status sourceStatus flips it to")), + "got: " + ex.getIssues()); + } + + /** + * {@code mode: append} is the ABSENCE of the guard, so no slot is ever consumed for a retired + * target to free - and returning the source would simply append another document. + */ + @Test + void rejectsAReopenOnAnAppendingCreateFrom() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED", mode: append } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("mode: append")), + "got: " + ex.getIssues()); + } + + /** + * A button-only create-from carries no guard at all, so nothing blocks a replacement - the button + * IS the reissue, and there is no trigger for a reopen to re-fire. The glue emits no listener for + * that shape, so accepting the key would authorise something that generates nothing. + */ + @Test + void rejectsAReopenWithoutAnEventTrigger() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("no event:") && i.contains("the button already reissues")), + "got: " + ex.getIssues()); + } + + /** + * What retires a target is the {@code stage:} classification of its nomenclature. Leave the seed + * rows unclassified and nothing can ever be recognised as retired, so the reopen would never fire - + * which is the exact silence this key exists to remove. + */ + @Test + void rejectsAReopenWhoseTargetNomenclatureIsUnclassified() { + IntentValidationException ex = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(GENERATES_REOPEN_HEAD.replaceAll(",\\s+stage: \\w+", "") + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("DeclarationState") && i.contains("stage:")), + "got: " + ex.getIssues()); + } + + /** A target with no lifecycle at all can never be retired. */ + @Test + void rejectsAReopenWhoseTargetCarriesNoLifecycle() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_EVENT_HEAD + """ + event: { onTransition: Fine, when: "Status == 2" } + map: { Fine: id } + sourceStatus: 3 + sourceStatusOnRetire: 2 + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("no function: EntityStatus relation") && i.contains("never be retired")), + "got: " + ex.getIssues()); + } + + /** + * A cross-model target is seeded in its owner model, so no {@code stage:} classification is + * resolvable at the consumer - the same limit a report {@code scope:} has, and the guard's own. + */ + @Test + void rejectsAReopenForACrossModelTarget() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(""" + name: timesheets + uses: + - { model: sales } + entities: + - name: TimesheetStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Timesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: TimesheetStatus, function: EntityStatus, init: 1 } + seeds: + - name: timesheet-statuses + entity: TimesheetStatus + rows: + - { id: 1, name: OPEN } + - { id: 2, name: APPROVED } + - { id: 3, name: INVOICED } + generates: + - name: invoice-from-timesheet + from: Timesheet + to: SalesInvoice + uses: sales + forEntity: Timesheet + event: { onTransition: Timesheet, when: "Status == APPROVED" } + map: { Timesheet: id } + sourceStatus: INVOICED + sourceStatusOnRetire: APPROVED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("cross-model target") && i.contains("stage:")), + "got: " + ex.getIssues()); + } + + /** + * The source stands at the completion status when the retirement arrives, so the graph is asked for + * that ONE edge - not for reachability. Without it the generated repository would reject the flip + * the moment it ran, and the author would learn about it from a runtime log. + */ + @Test + void rejectsAReopenTheSourceLifecycleHasNoEdgeFor() { + IntentValidationException ex = + assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD.replace(""" + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + """, """ + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + lifecycle: + edges: + - { from: DRAFT, to: [IDENTIFIED] } + - { from: IDENTIFIED, to: [DECLARED] } + """) + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("declares no edge from [DECLARED]")), + "got: " + ex.getIssues()); + } + + /** + * With the edge back declared, the same model parses - the graph states that the source may return. + */ + @Test + void aReopenTheSourceLifecycleDeclaresParses() { + IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD.replace(""" + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + """, """ + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + lifecycle: + edges: + - { from: DRAFT, to: [IDENTIFIED] } + - { from: IDENTIFIED, to: [DECLARED] } + - { from: DECLARED, to: [IDENTIFIED] } + """) + """ + event: { onTransition: Fine, when: "Status == IDENTIFIED" } + map: { Fine: id } + sourceStatus: DECLARED + sourceStatusOnRetire: IDENTIFIED + """); + assertEquals(2, model.getGenerates() + .get(0) + .getSourceStatusOnRetire()); + } + } 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 9ae143c7414..16f9d77340e 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 @@ -43,11 +43,11 @@ class GlueGenerator { /** The names of the collections this generator handles. */ - private static final List COLLECTIONS = - List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits", "aborts", "setters", "writers", - "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles", "outbound", "stepEvents", - "rollups", "expansions", "expansionCleanups", "settlements", "settlementListeners", "generates", "generateEvents", - "transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", "numbering", "resolves"); + private static final List COLLECTIONS = List.of("triggers", "resolvers", "fieldLoaders", "assignees", "timerLoaders", "waits", + "aborts", "setters", "writers", "notifications", "schedules", "integrations", "inbound", "inboundMessages", "inboundFiles", + "outbound", "stepEvents", "rollups", "expansions", "expansionCleanups", "settlements", "settlementListeners", "generates", + "generateEvents", "generateReopens", "transitions", "sends", "posts", "aggregates", "postings", "printFeeders", "snapshots", + "numbering", "resolves"); /** The renderer. */ private final ModelTemplateRenderer renderer; @@ -106,10 +106,11 @@ List generate(String collection, GenerationTemplateMetadataSource case "expansionCleanups" -> each(collection, source, content, model, parameters, GlueGenerator::bindExpansionCleanup); case "settlements" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlement); case "settlementListeners" -> each(collection, source, content, model, parameters, GlueGenerator::bindSettlementListener); - // Both collections carry the SAME create-from descriptors (generateEvents is the - // event-driven subset), so they share one binding - the listener and the create-from it - // calls cannot be rendered from divergent data. - case "generates", "generateEvents" -> each(collection, source, content, model, parameters, GlueGenerator::bindGenerate); + // All three collections carry the SAME create-from descriptors (generateEvents is the + // event-driven subset, generateReopens the declared-reopen one), so they share one binding - + // the listeners and the create-from they surround cannot be rendered from divergent data. + case "generates", "generateEvents", "generateReopens" -> each(collection, source, content, model, parameters, + GlueGenerator::bindGenerate); case "transitions" -> each(collection, source, content, model, parameters, GlueGenerator::bindTransition); case "sends" -> each(collection, source, content, model, parameters, GlueGenerator::bindSend); case "posts" -> each(collection, source, content, model, parameters, GlueGenerator::bindPost); @@ -624,6 +625,11 @@ private static void bindGenerate(Map item, Map c // a cancelled or voided document stops blocking its replacement. Gated on the boolean - a // .glue written before this key existed keeps the existence-only guard it always had. "hasRetiredStatus", "retiredStatusProperty", "retiredStatusCondition", + // The declared reopen (issue #6868): the status the SOURCE returns to when that target is + // retired, and the same retiring test rendered against the reopen listener's own local. + // The raw target perspective comes along because the listener binds the TARGET's topic, + // and a topic keeps the raw perspective while a package segment is sanitized. + "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"); @@ -641,6 +647,10 @@ private static void bindGenerate(Map item, Map c context.put("fromProjectName", truthy(item, "crossModelSource") ? str(item, "fromProject") : str(parameters, "projectName")); context.put("toGenFolder", truthy(item, "crossModel") ? sanitize(item, "toModel") : str(parameters, "javaGenFolderName")); context.put("toJavaPerspective", sanitize(item, "toPerspective")); + // The project publishing the TARGET's "-transitioned" topic, which only the reopen listener + // binds. A reopen is refused for a cross-model target - its retiring statuses are classified in + // the owner model and unresolvable here - so the target's project is always this one. + context.put("toProjectName", str(parameters, "projectName")); // A primary source item that is not a composition child lives outside the source document's // perspective, which the intent layer resolves; for the common case it is the same one. context.put("fromItemJavaPerspective", diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateReopen.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateReopen.java.template new file mode 100644 index 00000000000..0ce7c76a706 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/GenerateReopen.java.template @@ -0,0 +1,114 @@ +package gen.events.${javaGenFolderName}; + +import org.eclipse.dirigible.components.data.store.java.repository.Criteria; +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +import org.eclipse.dirigible.sdk.messaging.ListenerKind; +import org.eclipse.dirigible.sdk.messaging.MessageHandler; +import org.eclipse.dirigible.sdk.utils.Json; + +/** + * Declared reopen of the create-from ${name} (intent `sourceStatusOnRetire:`): when the ${toEntity} + * generated from a ${fromEntity} is RETIRED - its ${retiredStatusProperty} reaches a status the seeds + * classify `stage: cancelled` or `stage: void` - the ${fromEntity} is returned to status + * ${reopenStatusValue}, where it stood before that ${toEntity} existed. + * + * Generated from the intent generates block's `sourceStatusOnRetire:` (issue #6868) - do not edit; it + * is re-generated with the application. + * + * Why it exists: the completion hook moved the ${fromEntity} to status ${sourceStatusValue} when the + * ${toEntity} was created, deliberately - so the guard-claimed source stops matching its own trigger. + * The at-most-once guard steps over a retired ${toEntity} (issue #6814), which FREES the ${fromEntity}'s + * one-shot slot, but nothing could refill it: the ${fromEntity} stands at ${sourceStatusValue} and its + * lifecycle declares no way back into the status the trigger qualifies on, so no qualifying event was + * ever published again. This is the move back, declared. + * + * Contract: + * - binds the TARGET's -transitioned topic - the channel every routed status write publishes (a + * transitions button, a workflow setter, a create-from completion hook), so a void performed any of + * those ways is seen the same; + * - RE-LOADS the ${toEntity} before deciding, because the payload is as-of the event and a later + * transition may already have moved the document out of the retiring stage again; + * - acts only while the ${fromEntity} still stands where THIS create-from's completion hook left it, + * so a ${fromEntity} that has since moved on down its own lifecycle is never overruled; + * - and only while the slot is genuinely FREE - no ${toEntity} of this ${fromEntity} still counts. That + * is the create-from's own guard asked from here, and it is what makes the reopen idempotent under + * redelivery: a redelivered retirement of a ${toEntity} whose replacement already exists must not + * return the ${fromEntity} a second time. Idempotent by the state itself, with no marker column to + * keep in step; + * - flips ONLY the status column, through the targeted primitive, with the "-transitioned" notice + * riding that write into the outbox - so the flip and its announcement commit together and + * ${className}GenerateOnEvent cannot miss the moment that frees it. The reissue itself is then the + * ORDINARY path: the trigger re-fires, the guard steps over the retired document, and the + * replacement is minted. The retired ${toEntity} is kept, never edited and never re-pointed. + */ +@Component("${javaGenFolderName}_${className}GenerateReopen") +public class ${className}GenerateReopen implements MessageHandler { + + private static final Logger LOG = Logging.getLogger("gen.events.${javaGenFolderName}.${className}GenerateReopen"); + + @Override + public String destination() { + return "${toProjectName}-${toPerspective}-${toEntity}-transitioned"; + } + + @Override + public ListenerKind kind() { + return ListenerKind.TOPIC; + } + + @Override + public void onMessage(String message) { + gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity payload = + Json.parse(message, gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity.class); + if (payload == null || payload.${toPk} == null) { + return; + } + gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity target = + new gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Repository().findById(payload.${toPk}); + if (target == null || target.${retiredStatusProperty} == null || !(${reopenRetiredCondition})) { + // Not a retirement. Every other transition of this ${toEntity} - issued, sent, paid - leaves + // its ${fromEntity} exactly where it is. + return; + } + if (target.${backRefProperty} == null) { + return; // this row back-references no ${fromEntity}, so there is nothing to return + } + gen.${fromGenFolder}.data.${fromJavaPerspective}.${fromEntity}Repository sourceRepository = + new gen.${fromGenFolder}.data.${fromJavaPerspective}.${fromEntity}Repository(); + gen.${fromGenFolder}.data.${fromJavaPerspective}.${fromEntity}Entity source = + sourceRepository.findById(target.${backRefProperty}); + if (source == null || source.${sourceStatusProperty} == null + || source.${sourceStatusProperty} != ${sourceStatusValue}) { + // The ${fromEntity} is not standing where this create-from's completion hook left it: it was + // already returned, or it has moved on down its own lifecycle and the reissue is no longer + // this rule's to decide. + return; + } + // Is the slot actually free? The create-from's own at-most-once guard, asked from this end and + // over the SAME retiring classification: if any ${toEntity} of this ${fromEntity} still counts, + // a creation would hand that one back rather than mint a replacement, so returning the + // ${fromEntity} would leave it re-opened with a live ${toEntity} against it. That is precisely + // what a REDELIVERED retirement looks like once the replacement exists - and delivery is + // at-least-once. + for (gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Entity candidate : + new gen.${toGenFolder}.data.${toJavaPerspective}.${toEntity}Repository().findAll( + Criteria.create() + .eq("${backRefProperty}", target.${backRefProperty}))) { + if (candidate.${retiredStatusProperty} == null || !(${retiredStatusCondition})) { + return; + } + } + sourceRepository.updateProperties(target.${backRefProperty}, + java.util.Map.of("${sourceStatusProperty}", ${reopenStatusValue}), + "${fromProjectName}-${fromPerspective}-${fromEntity}-transitioned"); + LOG.info("Generate ${name}: ${toEntity} [{}] was retired - ${fromEntity} [{}] returned to status ${reopenStatusValue}", + target.${toPk}, target.${backRefProperty}); + } + + @Override + public void onError(String error) { + LOG.error("Generate ${name}: the reopen listener on [{}] failed - [{}]", destination(), error); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index e536a591991..10a67b4bf92 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -201,6 +201,16 @@ export function getTemplate(parameters) { engine: "velocity", collection: "generateEvents" }, + { + // The declared-reopen subset of `generates` (issue #6868): the listener that returns the + // source to its pre-generation status when the target it made is retired, so the ordinary + // trigger can mint the replacement - void and reissue with nobody clicking. + location: "/template-application-events-java/events/GenerateReopen.java.template", + action: "generate", + rename: "gen/events/{{javaGenFolderName}}/{{className}}GenerateReopen.java", + engine: "velocity", + collection: "generateReopens" + }, { location: "/template-application-events-java/events/Transition.java.template", action: "generate", diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 94fffd2a443..a155f88e8ef 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -2924,6 +2924,117 @@ void generates_completion_hook_flips_the_source_via_targeted_update() { "the action label must land in the en catalog's actions section, got: " + actionCatalog); } + @Test + void generates_reopen_returns_the_source_when_its_target_is_retired() { + // The other half of the completion hook (#6868). `sourceStatus:` moves the Proforma OFF the + // status its own trigger qualifies on, deliberately - so the guard-claimed source stops matching. + // The at-most-once guard learned to step over a RETIRED target (#6814), which frees the + // Proforma's one-shot slot, but nothing could refill it: the Proforma stands at INVOICED and its + // lifecycle offers no way back to APPROVED, so no qualifying -transitioned was ever published + // again and this event-only create-from had no reissue path at all. `sourceStatusOnRetire:` + // declares the move back, and the reissue is then the ORDINARY path. + String genYaml = """ + name: reissue + entities: + - name: ProformaStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - 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 } + relations: + - { name: Proforma, kind: manyToOne, to: Proforma } + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + generates: + - name: invoice-from-proforma + from: Proforma + to: Invoice + forEntity: Proforma + event: { onTransition: Proforma, when: "Status == APPROVED" } + map: { Proforma: id } + sourceStatus: INVOICED + sourceStatusOnRetire: APPROVED + seeds: + - name: proforma-statuses + entity: ProformaStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: APPROVED } + - { id: 3, name: INVOICED } + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT, stage: draft } + - { id: 2, name: ISSUED, stage: live } + - { id: 3, name: CANCELLED, stage: cancelled } + - { id: 4, name: VOIDED, stage: void } + """; + writeIntent(genYaml); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + + generateFromModel("template-application-events-java/template/template.js", "reissue.glue"); + String reopen = codeOf("gen/events/reissue/InvoiceFromProformaGenerateReopen.java"); + // It listens on the TARGET's -transitioned topic - the channel every routed status write + // publishes, so a void performed by a transitions button, a workflow setter or another + // completion hook is seen the same way. + assertTrue(reopen.contains("implements MessageHandler"), "the reopen must be a self-describing message handler"); + assertTrue(reopen.contains("return \"" + PROJECT + "-Invoice-Invoice-transitioned\""), + "the reopen must bind the TARGET's -transitioned topic, got: " + reopen); + // What counts as retired is the seeds' `stage:` classification and nothing else - both retiring + // stages, in seed order, and NOT the draft/live ones. Same resolution as the guard's own, which + // is why the two cannot disagree. + assertTrue(reopen.contains("!(target.Status == 3 || target.Status == 4)"), + "only a cancelled/void target may reopen the source, got: " + reopen); + // It finds the source through the very back-reference the guard reads. + assertTrue(reopen.contains("findById(target.Proforma)"), "the reopen must reach the source through the back-reference"); + // ...and acts only while the source still stands where THIS create-from's hook left it: that is + // what makes it idempotent under redelivery, with no marker column to keep in step. + assertTrue(reopen.contains("source.Status != 3"), + "the reopen must act only while the source stands at the completion status, got: " + reopen); + // ...and only while the slot is genuinely free - the create-from's own guard asked from this end, + // over the SAME retiring classification. Delivery is at-least-once, so a REDELIVERED retirement + // arrives after the replacement already exists; without this the source would be re-opened with a + // live Invoice standing against it. + assertTrue( + reopen.contains("InvoiceEntity candidate :") && reopen.contains(".eq(\"Proforma\", target.Proforma)") + && reopen.contains("!(candidate.Status == 3 || candidate.Status == 4)"), + "the reopen must refuse while any target of the source still counts, got: " + reopen); + // ONE targeted status write, with the source's "-transitioned" notice riding it into the outbox - + // flip and announcement commit together, so the create-from's own listener cannot miss the moment + // that frees it. Anchored on the call, since the comments name the topic too. + assertTrue(reopen.contains("java.util.Map.of(\"Status\", 2),"), "the reopen must write only the status column, got: " + reopen); + assertTrue(reopen.contains("\"" + PROJECT + "-Proforma-Proforma-transitioned\");"), + "the write must carry the SOURCE's -transitioned topic, or the trigger can never re-fire"); + assertFalse(reopen.contains("Producer.sendToTopic"), + "the reopen must not publish beside its write - a broker outage would lose the announcement"); + + // The event-driven create-from itself is unchanged: it still delegates to the same create(), and + // its guard still steps over the retired document - which is what mints the replacement once the + // reopen has re-published the source's transition. + String onEvent = codeOf("gen/events/reissue/InvoiceFromProformaGenerateOnEvent.java"); + assertTrue(onEvent.contains("source.Status != 2"), "the trigger still qualifies on the status the source is returned to"); + String generate = codeOf("gen/events/reissue/InvoiceFromProformaGenerate.java"); + assertTrue(generate.contains("if (candidate.Status == null || !(candidate.Status == 3 || candidate.Status == 4)) {"), + "the at-most-once guard must step over the retired target the reopen reacts to"); + } + @Test void multilingual_entity_generates_the_translation_stack() { writeIntent(INTENT_YAML);