Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,13 @@ import org.eclipse.dirigible.sdk.utils.Json;
* re-generated with the application.
*
* Contract:
#if($isStep)
* - binds the step-scoped topic the generated step emitter publishes the ${fromEntity} on at that
* - binds the #if($isStep)step-scoped topic the generated step emitter publishes the ${fromEntity} on at that
* step's boundary - published after commit and NOT transactional with the step, so a redelivery
* is possible;
#elseif($isCreate)
* - binds the source's create topic (the platform publishes creates unsuffixed);
#{else}
* - binds the source's -transitioned topic - the channel every status write publishes,
* which -updated reactions never see, so there are no loops;
#end
* is possible#elseif($isCreate)source's create topic (the platform publishes creates unsuffixed)#{else}source's -transitioned topic - the channel a status write ROUTED BY THE
* WORKFLOW publishes (a transition button, a setField step, a create-from completion hook, a
* register lookup that sets a status), which -updated reactions never see, so there are no loops.
* It is NOT every status write: a plain REST update() publishes only -updated, and a targeted
* updateProperty/updateProperties write publishes nothing unless its own template publishes#end;
* - RE-LOADS the source by id before the guard, because the event payload is as-of the event and lacks
* later-step data such as a stamped document number;
* - delegates to ${className}Generate, the SAME create-from a button would call, so the mapping, the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import gen.${javaGenFolderName}.data.${javaRegisterPerspective}.${registerEntity
* and a re-delivered event is a no-op. The write is TARGETED (updateProperties) - only the resolved
* column, the outcome and the status are in the UPDATE statement, so a concurrent user write to any
* other column cannot be reverted, and no "-updated" event re-fires.
*
* When an outcome routes the record by status, the "-transitioned" topic IS published afterwards, so
* that an automatic resolution reaches the same constructs a manual transition does - `generates:` and
* `postings:` bound to `event: { onTransition: ... }`. A targeted write raises no event of its own, so
* this publish is the only thing that makes the automatic path observable at all.
*/
@Component("${javaGenFolderName}_${className}Resolve")
public class ${className}Resolve implements MessageHandler {
Expand Down Expand Up @@ -108,6 +113,9 @@ public class ${className}Resolve implements MessageHandler {
/**
* One targeted write carrying everything this attempt decided: the resolved relation when there is
* one, the outcome trace, and the routing status. Nothing else of the record is touched.
*
* A targeted write publishes NO event, so when this lookup routes the record by status it must
* announce that transition itself - see the "-transitioned" publish below.
*/
private static void stamp(Object id, String outcome, Integer resolved#if($writesStatus == "true"), Integer status#end) {
java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
Expand All @@ -125,6 +133,22 @@ public class ${className}Resolve implements MessageHandler {
if (!values.isEmpty()) {
new ${entity}Repository().updateProperties(id, values);
}
#if($writesStatus == "true")
if (status != null) {
// The status this lookup just routed the record to IS a transition, and the constructs that
// react to one - `generates:` and `postings:` bound to `event: { onTransition: ... }` - listen
// on "-transitioned". updateProperties above publishes nothing at all, so without this the
// AUTOMATIC path silently did nothing while the manual one (a `transitions:` button, which
// does publish) worked: exactly the wrong way round, and with no log line to show it.
// Reload so the payload carries the committed row rather than the pre-write snapshot, the
// same shape the transition controller and the create-from completion hook publish.
${entity}Entity transitioned = new ${entity}Repository().findById(id);
if (transitioned != null) {
org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic("${projectName}-${perspective}-${entity}-transitioned",
Json.stringify(transitioned));
}
}
#end
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,51 @@ class IntentEmissionCoverageIT extends IntegrationTest {
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }

# resolves: (#6712) the effective-dated register lookup and, crucially, what happens
# AFTER it succeeds. Zone is the match key, Inspector is what gets resolved, Duty is the
# register whose validity period decides which Inspector was on duty on the patrol's date.
- name: Zone
kind: setting
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- name: Inspector
kind: setting
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
# The register: exactly ONE to-one to Inspector (what `set:` resolves), the match key,
# and the period. Two rows for one Zone with non-overlapping windows, so only the row
# covering the patrol's date can match - a lookup that ignored the period would pick the
# wrong Inspector rather than none, which is the failure worth catching.
- name: Duty
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: validFrom, type: date }
- { name: validTo, type: date }
relations:
- { name: Zone, kind: manyToOne, to: Zone }
- { name: Inspector, kind: manyToOne, to: Inspector }
# The record the lookup fills in. Its status is routed by the outcome, and that status
# write is the transition the generates below is bound to.
- name: Patrol
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: visitedAt, type: date }
- { name: outcome, type: string, length: 20 }
relations:
- { name: Zone, kind: manyToOne, to: Zone }
- { name: Inspector, kind: manyToOne, to: Inspector }
- { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 }
# The downstream proof: this exists only if the automatic resolution published
# "-transitioned". The back-reference to Patrol is also the at-most-once guard.
- name: PatrolReport
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string, length: 100 }
relations:
- { name: Patrol, kind: manyToOne, to: Patrol }

# generates step axis + mode: append (#6800): a shipment whose flow is observed at two
# moments, a log the observations are APPENDED to (several rows per source - the
# cardinality the at-most-once create-from could not express), and a summary minted
Expand Down Expand Up @@ -1155,6 +1200,33 @@ class IntentEmissionCoverageIT extends IntegrationTest {
prompt:
- { field: note }
- { field: amount, required: true }
# The automatic half of the same axis: the create-from below is triggered by a status
# the REGISTER LOOKUP wrote, not by a button and not by a workflow step. The lookup
# persists that status with a targeted write, which publishes no event of its own, so
# this entry only ever fires if the lookup announces the transition itself.
- name: report-from-patrol
from: Patrol
to: PatrolReport
event: { onTransition: Patrol, when: "Status == POSTED" }
map:
Patrol: id
defaults:
note: "AUTO"

# resolves: (#6712) fill Patrol.Inspector from the Duty row whose validity period covers
# the patrol's date, stamp the outcome, and route the record by status. The status write is
# the point of the coverage: it is a transition like any other, and the generates entry
# above is bound to it - so this asserts the AUTOMATIC path reaches the same consumers the
# manual one does, which is exactly what silently did not happen.
resolves:
- name: assign-inspector
event: { onCreate: Patrol }
set: Inspector
from: Duty
match: { Zone: Zone }
between: { start: validFrom, end: validTo, value: visitedAt }
outcome: outcome
found: { setStatus: POSTED }

# The roles the model issues - and the ones a `visibleTo:` field may name.
permissions:
Expand Down Expand Up @@ -1201,6 +1273,23 @@ class IntentEmissionCoverageIT extends IntegrationTest {
- { id: 1, name: DRAFT, stage: draft }
- { id: 2, name: POSTED, stage: live }
- { id: 3, name: CANCELLED, stage: cancelled }
- name: zones
entity: Zone
rows:
- { id: 1, name: North }
- name: inspectors
entity: Inspector
rows:
- { id: 1, name: Early }
- { id: 2, name: Late }
# Two duties for one zone, windows that do not overlap: the visit's date falls inside
# the SECOND, so resolving to inspector 2 can only mean the period was honoured - a
# lookup that matched on the zone alone would answer 1 (or refuse as ambiguous).
- name: duties
entity: Duty
rows:
- { id: 1, Zone: 1, Inspector: 1, validFrom: "2020-01-01", validTo: "2020-12-31" }
- { id: 2, Zone: 1, Inspector: 2, validFrom: "2021-01-01", validTo: "2021-12-31" }
- name: units
entity: Unit
rows:
Expand Down Expand Up @@ -2453,6 +2542,26 @@ private void assertEmission() {
assertTrue(contentOf("gen/events/emission/ShipmentFlowSettleCompleted.java").contains("implements JavaDelegate"),
"a create-from asking for a step moment must get that moment's emitter, even as its only consumer");

// resolves (#6712): the lookup persists its outcome with a TARGETED write, which publishes no
// event at all - so when that write routes the record by status it has to announce the
// transition itself. Without this the automatic path wrote the status and told nobody, while a
// transitions: button on the same entity worked: the primary path silently dead, the fallback
// fine. The consumers are bound to "-transitioned" (see the create-from above), so that is the
// channel it must publish on.
String resolve = contentOf("gen/events/emission/AssignInspectorResolve.java");
assertTrue(resolve.contains("updateProperties("), "the lookup must persist its outcome as one targeted write");
assertTrue(resolve.contains("-Patrol-transitioned"),
"a resolve that routes the record by status must publish the record's -transitioned topic, "
+ "or nothing bound to onTransition can ever observe an automatic resolution");
assertTrue(resolve.contains("Producer.sendToTopic"), "the resolve must publish through the messaging producer");
// Guarded on a status having been written: a lookup that only filled the relation (or found
// nothing) transitioned nothing, and must not announce one.
assertTrue(resolve.indexOf("if (status != null)") < resolve.indexOf("Producer.sendToTopic"),
"the publish must sit under the status guard, so a lookup that wrote no status announces no transition");
String reportOnEvent = contentOf("gen/events/emission/ReportFromPatrolGenerateOnEvent.java");
assertTrue(reportOnEvent.contains("-Patrol-transitioned"),
"the create-from driven by the lookup must listen on the very topic the lookup publishes");

// generates prompt (#6685): the prompted controller takes a values map, enforces the
// required input with a 400, and converts each posted value to the target field's Java type
// (decimal -> BigDecimal). The action descriptor carries the prompt so the customActions
Expand Down Expand Up @@ -3642,9 +3751,59 @@ private void assertRuntimeEnforcement() {
assertOutboundDepartureRuntime();
assertExpansionLifecycleRuntime();
assertBpmEventsRuntime();
assertResolveTransitionRuntime();
assertGeneratesStepAxisRuntime();
}

/**
* The register lookup end to end (#6712), and specifically what happens once it succeeds: the
* resolved relation and the routing status are written, and the create-from bound to that
* transition mints its document - with nobody clicking anything.
*
* <p>
* The last step is the one worth having. The lookup persists through a targeted write, which
* publishes no event, so an automatic resolution used to reach no consumer at all while a manual
* transition on the same entity reached every one of them - the automation's primary path silently
* doing nothing, its fallback working, and no log line either way. Asserting only that the
* Inspector was filled in would still pass in that world.
*/
private void assertResolveTransitionRuntime() {
String patrolApi = API + "/patrol/PatrolController";
AtomicInteger patrol = new AtomicInteger();
// Zone 1 has two duties; this date falls inside the SECOND window only.
restAssuredExecutor.execute(() -> patrol.set(given().contentType("application/json")
.body("{\"Zone\":1,\"VisitedAt\":\"2021-06-15\"}")
.when()
.post(patrolApi)
.then()
.statusCode(200)
.extract()
.path("Id")));
// The lookup runs off the create event, so it lands after the POST returns.
restAssuredExecutor.execute(() -> given().when()
.get(patrolApi + "/" + patrol.get())
.then()
.statusCode(200)
// Inspector 2, not 1: the covering period decided it, not the zone alone.
.body("Inspector", equalTo(2))
.body("Outcome", equalTo("found"))
.body("Status", equalTo(2)),
90);
// The transition the lookup wrote must have reached the create-from bound to it.
restAssuredExecutor.execute(() -> {
io.restassured.path.json.JsonPath reports = given().when()
.get(API + "/patrolreport/PatrolReportController")
.then()
.statusCode(200)
.extract()
.jsonPath();
assertEquals(1, reports.getList("findAll { it.Patrol == " + patrol.get() + " }")
.size(),
"the create-from bound to onTransition must mint its document from an AUTOMATIC resolution, "
+ "not only from a transition button");
}, 90);
}

/**
* The step axis and both cardinalities of an event-driven create-from (#6800) at the outermost
* layer, on ONE shipment whose all-serviceTask flow runs by itself: the two appending create-froms
Expand Down
Loading