Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -856,18 +856,20 @@ private static List<Map<String, Object>> buildGenerates(IntentModel model, Map<S
}

/**
* The event half of a create-from (issue #6711), pre-rendered onto its glue entry: the trigger kind
* (an {@code onCreate} binds the source's bare create topic, an {@code onTransition} its
* {@code -transitioned} topic), the status guard as a property/value pair evaluated against the
* RE-LOADED source, and the back-reference the at-most-once guard reads.
* The event half of a create-from (issues #6711, #6800), pre-rendered onto its glue entry: the
* topic suffix the listener binds to (an {@code onCreate} the source's bare create topic, an
* {@code onTransition} its {@code -transitioned} topic, a step binding the step-scoped topic the
* generated emitter publishes to), the optional status guard as a property/value pair evaluated
* against the RE-LOADED source, the cardinality, and the back-reference.
*
* <p>
* The back-reference is DERIVED from the {@code map} entry that copies the source's primary key
* rather than declared a second time: the mapping already says which target property points back at
* the source, and two ways to say it could only drift. A missing one fails loudly here because
* without it the create-from has no way to recognize its own output, so an event redelivery would
* mint a duplicate document (the parser catches the local case earlier, with the fix in the
* message).
* without it the create-from has no way to recognize its own output (under {@code mode: once} an
* event redelivery would mint a duplicate document; under {@code mode: append} the appended row
* would not say what it is about) - the parser catches the local case earlier, with the fix in the
* message.
*
* @param g the create-from
* @param e the glue entry being built
Expand All @@ -877,6 +879,9 @@ private static void putGeneratesEvent(GeneratesIntent g, Map<String, Object> e,
e.put("hasEvent", g.isEventDriven());
if (!g.isEventDriven()) {
e.put("isCreate", false);
e.put("isStep", false);
e.put("topicSuffix", "");
e.put("appendMode", false);
e.put("guardProperty", "");
e.put("guardValue", "");
e.put("backRefProperty", "");
Expand All @@ -885,6 +890,15 @@ private static void putGeneratesEvent(GeneratesIntent g, Map<String, Object> e,
boolean isCreate = g.getEvent()
.get("onCreate") != null;
e.put("isCreate", isCreate);
StepEventSupport.Binding step = StepEventSupport.binding(g.getEvent());
e.put("isStep", step != null);
e.put("stepProcess", step == null ? "" : step.process());
e.put("stepName", step == null ? "" : step.step());
e.put("topicSuffix",
step != null ? StepEventSupport.topicSuffix(step.process(), step.step(), step.kind()) : isCreate ? "" : "-transitioned");
// The cardinality (#6800): `append` drops the existing-target lookup in the create-from, so
// every delivery of the event creates a row. It is the absence of a guard, not another guard.
e.put("appendMode", g.isAppendMode());
String guardProperty = "";
String guardValue = "";
Object whenValue = g.getEvent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.FieldIntent;
import org.eclipse.dirigible.components.intent.model.GeneratesIntent;
import org.eclipse.dirigible.components.intent.model.IntegrationIntent;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.model.NotificationIntent;
Expand All @@ -35,8 +36,9 @@
* the id in the (clear-D) process context and publishes its JSON to a step-scoped topic. That topic
* is the entity's own topic plus {@link #topicSuffix(Map) a step suffix}, so every consumer the
* lifecycle events already feed - notifications with their relation loads, guards and print
* attachments, integrations, outbound departures - binds to it and reads the payload unchanged. The
* action vocabulary is therefore reused literally, not re-implemented per event kind.
* attachments, integrations, outbound departures, event-driven create-froms - binds to it and reads
* the payload unchanged. The action vocabulary is therefore reused literally, not re-implemented
* per event kind.
*
* <p>
* The record a step event is about is the process's trigger entity: a process runs on one record,
Expand Down Expand Up @@ -261,6 +263,12 @@ private static List<Map<String, Object>> boundEvents(IntentModel model) {
for (OutboundIntent outbound : model.getOutbound()) {
events.add(outbound.getEvent());
}
// A create-from bound to a step (#6800) is a consumer like any other, and may well be the ONLY
// one of that moment - without it here the moment would have no emitter and the listener would
// wait on a topic nobody publishes to.
for (GeneratesIntent generates : model.getGenerates()) {
events.add(generates.getEvent());
}
return events;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@
*/
public class GeneratesIntent {

/**
* The default event cardinality: at most one target row per source, guarded by the back-reference.
*/
public static final String MODE_ONCE = "once";

/** The opt-in event cardinality: one target row per delivered event, with no guard at all. */
public static final String MODE_APPEND = "append";

/**
* Unique name within the model; drives the action id, the contribution files and the controller.
*/
Expand Down Expand Up @@ -106,17 +114,29 @@ public class GeneratesIntent {

/**
* Optional event trigger (issue #6711): the create-from runs by itself when the SOURCE reaches a
* state, instead of waiting for a click. {@code onTransition} (a status write; a {@code when}
* status guard is mandatory) or {@code onCreate} (the source's insert; the guard is optional) names
* the source entity - the same one {@link #from} declares, repeated for symmetry with
* {@code postings}' event axis and validated against it. The owning model is NOT repeated here:
* {@link #fromUses} already declares it.
* moment, instead of waiting for a click. Exactly one of two axes, the same pair the rest of the
* declarative glue binds to:
* <ul>
* <li>the source's <b>lifecycle</b> - {@code onTransition} (a status write; a {@code when} status
* guard is mandatory) or {@code onCreate} (the source's insert; the guard is optional), naming the
* source entity - the same one {@link #from} declares, repeated for symmetry with {@code postings}'
* event axis and validated against it; and</li>
* <li>a <b>process step</b> (issue #6800) - {@code onStepReached} / {@code onStepCompleted}:
* <code>{ process, step }</code>, the axis {@code notifications} / {@code integrations} /
* {@code outbound} already use, whose record is the process's trigger entity (which must be
* {@link #from}). The {@code when} guard stays optional: the step IS the moment.</li>
* </ul>
* The owning model is NOT repeated here: {@link #fromUses} already declares it.
*
* <p>
* An event-driven create-from is <b>at-most-once</b>: the target's back-reference to the source
* (the {@link #map} entry copying the source's primary key) is checked before anything is created,
* so an event redelivery - and a click on a button that is still declared - is a no-op that returns
* the document that already exists.
* {@code mode} declares the <b>cardinality</b>. The default {@code once} is at-most-once: the
* target's back-reference to the source (the {@link #map} entry copying the source's primary key)
* is checked before anything is created, so an event redelivery - and a click on a button that is
* still declared - is a no-op that returns the document that already exists. {@code append} drops
* that lookup, so every delivery of the event creates a row: the "one log/protocol row per step,
* per transition" shape. It is the ABSENCE of a guard, not a state-aware one - a redelivery appends
* a duplicate, and a replacement for a voided target is not what it expresses. The back-reference
* is required in BOTH modes: the dedup key in {@code once}, the row's provenance in {@code append}.
*/
private Map<String, Object> event;

Expand Down Expand Up @@ -267,6 +287,27 @@ public boolean isEventDriven() {
return event != null && !event.isEmpty();
}

/**
* The declared cardinality of the event trigger (see {@link #event}).
*
* @return {@link #MODE_APPEND} when the author asked for a row per event, {@link #MODE_ONCE}
* otherwise (the default, and the value for a create-from with no event at all)
*/
public String getEventMode() {
Object mode = event == null ? null : event.get("mode");
String declared = mode == null ? null
: mode.toString()
.trim();
return declared == null || declared.isEmpty() ? MODE_ONCE : declared;
}

/**
* @return whether every delivery of the event appends a target row (no at-most-once guard)
*/
public boolean isAppendMode() {
return MODE_APPEND.equals(getEventMode());
}

public Boolean getButton() {
return button;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5338,7 +5338,7 @@ private static void validateGenerates(IntentModel model, Set<String> entityNames
if (!"entity".equals(scope) && !"page".equals(scope)) {
issues.add("generates [" + name + "] has invalid scope [" + scope + "] (expected 'entity' or 'page')");
}
validateGeneratesEvent(g, name, source, crossModelSource, issues);
validateGeneratesEvent(g, name, source, crossModelSource, model, issues);
validateMapSource(source, g.getMap(), "generates [" + name + "]", "map", issues);
if (g.getItems() != null) {
GeneratesItemsIntent items = g.getItems();
Expand Down Expand Up @@ -5441,16 +5441,21 @@ private static void validateGeneratesPrompt(GeneratesIntent g, String name, Map<
}

/**
* Validate the optional {@code event} trigger of a create-from (issue #6711): exactly one of
* {@code onTransition} (a status write - the {@code when} status guard is mandatory) or
* {@code onCreate} (the source's insert - the guard is optional), naming the SAME entity
* {@code from} declares; the owning model is never repeated here, {@code fromUses} declares it. An
* event-driven create-from is at-most-once, and the guard is the target's back-reference to the
* source - so the {@code map} must copy the source's primary key onto it. Without an event,
* {@code button: false} is rejected: a create-from with neither trigger generates nothing at all.
* Validate the optional {@code event} trigger of a create-from (issues #6711, #6800): exactly one
* of the source's lifecycle ({@code onTransition} - a status write, the {@code when} status guard
* is mandatory - or {@code onCreate} - the source's insert, the guard optional), naming the SAME
* entity {@code from} declares, or a process step ({@code onStepReached}/{@code onStepCompleted}:
* <code>{ process, step }</code>) whose process runs ON that entity. The owning model is never
* repeated here, {@code fromUses} declares it.
*
* <p>
* The {@code map} must copy the source's primary key onto the target's back-reference in BOTH
* cardinalities: it is the at-most-once guard of the default {@code mode: once}, and the row's
* provenance under {@code mode: append}. Without an event, {@code button: false} is rejected: a
* create-from with neither trigger generates nothing at all.
*/
private static void validateGeneratesEvent(GeneratesIntent g, String name, EntityIntent source, boolean crossModelSource,
List<String> issues) {
IntentModel model, List<String> issues) {
String subject = "generates [" + name + "]";
if (!g.isEventDriven()) {
if (Boolean.FALSE.equals(g.getButton())) {
Expand All @@ -5463,11 +5468,21 @@ private static void validateGeneratesEvent(GeneratesIntent g, String name, Entit
if (event.get("model") != null) {
issues.add(subject + " event must not declare model: - the source and its owning model are declared by from:/fromUses:");
}
validateGeneratesEventMode(g, subject, issues);
Object onTransition = event.get("onTransition");
Object onCreate = event.get("onCreate");
if (onTransition == null && onCreate == null) {
issues.add(subject + " event requires `onTransition: " + g.getFrom() + "` (a status write) or `onCreate: " + g.getFrom()
+ "` (the source's insert)");
String stepKind = null;
for (String kind : STEP_EVENT_KINDS) {
if (event.get(kind) != null) {
stepKind = kind;
}
}
if (stepKind != null) {
validateGeneratesStepEvent(g, subject, stepKind, onTransition != null || onCreate != null, crossModelSource, model, issues);
} else if (onTransition == null && onCreate == null) {
issues.add(subject + " event requires `onTransition: " + g.getFrom() + "` (a status write), `onCreate: " + g.getFrom()
+ "` (the source's insert) or `onStepReached`/`onStepCompleted: { process: <Process>, step: <step> }`"
+ " (a moment in a process that runs on it)");
} else if (onTransition != null && onCreate != null) {
issues.add(subject + " event declares both onTransition and onCreate - exactly one trigger is allowed");
} else {
Expand All @@ -5488,14 +5503,73 @@ private static void validateGeneratesEvent(GeneratesIntent g, String name, Entit
issues.add(subject + " event when [" + when + "] must be `<Property> == <status seed id or name>`");
}
}
// The at-most-once guard: the target's own to-one back to the source, written from the source's
// primary key. A cross-model source's key field is read from the owner .model at generation
// time, so only the local case is checkable here - the glue generator fails loudly for the rest.
// The back-reference: the target's own to-one back to the source, written from the source's
// primary key. Required in BOTH cardinalities - the at-most-once guard under `once`, the row's
// provenance under `append` (a log row nothing points back at cannot be read). A cross-model
// source's key field is read from the owner .model at generation time, so only the local case
// is checkable here - the glue generator fails loudly for the rest.
if (!crossModelSource && source != null && !g.getMap()
.containsValue(seedIdField(source))) {
issues.add(subject + " is event-driven, so its map must copy the source's [" + seedIdField(source)
+ "] onto the target's back-reference to it (e.g. `map: { " + g.getFrom() + ": " + seedIdField(source)
+ " }`) - that back-reference is the at-most-once guard against an event redelivery");
+ " }`) - that back-reference is the at-most-once guard against an event redelivery under mode: once,"
+ " and the created row's provenance under mode: append");
}
}

/**
* The {@code mode} of an event trigger (issue #6800): {@code once} (the default - at most one
* target row per source) or {@code append} (a row per delivered event). Anything else is refused
* rather than silently read as the default, which would turn a typo into a cardinality nobody
* authored.
*/
private static void validateGeneratesEventMode(GeneratesIntent g, String subject, List<String> issues) {
Object mode = g.getEvent()
.get("mode");
if (mode == null) {
return;
}
String declared = String.valueOf(mode)
.trim();
if (!GeneratesIntent.MODE_ONCE.equals(declared) && !GeneratesIntent.MODE_APPEND.equals(declared)) {
issues.add(subject + " event has invalid mode [" + declared + "] (expected '" + GeneratesIntent.MODE_ONCE + "' - at most one "
+ g.getTo() + " per " + g.getFrom() + " - or '" + GeneratesIntent.MODE_APPEND + "' - one per delivered event)");
}
}

/**
* A create-from bound to a process step: the step must be an observable moment of a process that
* runs ON the source (the step event is delivered as a message about the process's trigger entity,
* which is what the create-from then reads by id), and the source must be local - a process and its
* steps belong to the model that declares them, so a cross-model source has none to bind to here.
* The {@code when} guard stays optional: the step already IS the moment.
*/
private static void validateGeneratesStepEvent(GeneratesIntent g, String subject, String kind, boolean lifecycleToo,
boolean crossModelSource, IntentModel model, List<String> issues) {
if (lifecycleToo) {
issues.add(subject + " event declares " + kind + " next to onTransition/onCreate - exactly one trigger is allowed");
return;
}
if (crossModelSource) {
issues.add(subject + " event binds " + kind + " on a cross-model source (fromUses [" + g.getFromUses()
+ "]) - a process and its steps are local to the model that declares them; bind to onTransition/onCreate instead");
return;
}
String triggerEntity = validateStepEventBinding(g.getEvent(), kind, subject, model, issues);
if (triggerEntity == null) {
return; // already reported
}
if (g.getFrom() != null && !g.getFrom()
.isBlank()
&& !triggerEntity.equals(g.getFrom())) {
issues.add(subject + " event " + kind + " names a process that runs on [" + triggerEntity + "], not on the from entity ["
+ g.getFrom() + "] - a step event is about the record its process runs on, which is the record the create-from reads");
}
Object when = g.getEvent()
.get("when");
if (when != null && !String.valueOf(when)
.matches("\\s*\\w+\\s*==\\s*\\d+\\s*")) {
issues.add(subject + " event when [" + when + "] must be `<Property> == <status seed id or name>`");
}
}

Expand Down
Loading
Loading