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
2 changes: 2 additions & 0 deletions .claude/docs/intent-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt

**The enrichment channel (`phases:` + `onPhase`, [#6929](https://github.com/eclipse-dirigible/dirigible/issues/6929)):** a value a listener computes AFTER the insert — a moving-average cost, a snapshot column, an external lookup — must be written back **event-silently** or it re-fires every onUpdate consumer of a change the user never made; so it published nothing at all, and a declarative consumer of that value had no moment to bind. Bound to `onCreate` it RACED the enrichment (two listeners on one topic have no order — each `MessageHandler` is its own durable subscriber, and there is no priority anywhere), and posted a balanced-looking journal entry for a null amount with parse, generation, compile and publish all green. The fix is a CHANNEL, not an ordering contract the broker cannot keep: an entity declares the moments it announces (`phases: [costed]`), the Java DAO template emits one **`announce<Phase>(id, values)`** per phase — `updateProperties` with the phase's own topic, so the enrichment and its notice ride ONE write into the outbox and commit together — and any glue consumer binds `event: { onPhase: <Entity>, phase: <name> }`. The generated method is the point: a hand-typed topic string reproduces exactly the silence being removed, a mistyped `announceCosted` is a compile error. Accepted by `postings:` (the driver), `notifications:`, `integrations:`, `outbound:` and an event-driven `generates:`, with the `when:` guard optional there (the phase already IS one moment); deliberately not by a process `trigger:`, a `wait` or `resolves:`. Refused at parse, each because it is otherwise silent: a phase that is not a lower-camel identifier, one named after a platform channel (`updated`/`deleted`/`transitioned`/`rekeyed`), a duplicate, a `phase:` key on another axis, and a binding naming a phase the entity does not declare. Details in the engine-intent guide's phases bullet.

**A create-from is not offered twice (`fromStatus:`, [#7068](https://github.com/eclipse-dirigible/dirigible/issues/7068)):** a `generates:` with a `sourceStatus:` completion hook flipped its source once the target existed and then went on offering the same button on the flipped record - and answering the same endpoint 200 - so a second click minted a **second document**: a proforma already INVOICED produced a second invoice, in the customer's hands. The hook declared what "already done" looks like; nothing consulted it. A create-from now carries a from-status guard resolved ONCE and fed to both halves of the action: the generated `run()` refuses with **409** before anything is created, and the contributed action descriptor carries the same guard so the shared `customActions` store stops OFFERING the click on a record it would refuse (`getActions(view, type, record)` takes the record the view already has). Two shapes: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently only because `from:` on a create-from already names the source ENTITY - and absent it a declared `sourceStatus` IMPLIES the deny-list of exactly that status, so a model that already carries the defect is fixed with no authoring change. The guard is on the CLICK: an event-driven create-from keeps its own at-most-once back-reference guard and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is refused at parse rather than silently ignored - as are a `page` scope, a source with no `function: EntityStatus` relation, and an allow-list containing the `sourceStatus` the action itself writes.

**An amended source rewrites its posting ([#7071](https://github.com/eclipse-dirigible/dirigible/issues/7071)):** the amend path (Confirm → Reject → edit the lines → Issue again) raises a `postings:` trigger a SECOND time, and the old idempotency test - an existing post whose item count reached the derived one - read that as "already posted", so the journal entry silently kept the amounts of the previous issue while the invoice it references had moved on. No second entry (right), a ledger short by the difference (wrong), and nothing anywhere said so. The generated handler now derives the WHOLE content first and compares it with what the post carries: identical is a redelivery (no-op), different is either a half-post to complete or an amendment to REWRITE the post from - header assignments re-applied, items replaced, never a second document. The rewrite stops where the created document's own lifecycle says someone has taken it over: it is rewritable only while its `function: EntityStatus` relation still holds the `init:` the posting's own create wrote (with no status lifecycle there is nothing to act on, so it is always rewritable), and past that the divergence is logged naming both documents and left to a correcting entry - `reverses:` - rather than overwritten behind the accountant's back. The comparison is order-insensitive over every cell the item rows assign, and numbers compare by value so a rescaled amount is not a change.

**Event-driven create-from (`generates` + `event:`, [#6711](https://github.com/eclipse-dirigible/dirigible/issues/6711)):** a `generates` entry may declare `event: { onTransition: <from>, when: "<Status> == <status>" }` (guard mandatory) or `{ onCreate: <from> }` and mint the follow-up **document — header AND items** by itself when the source reaches a state, instead of waiting for the button (`posts` is event-driven but emits flat rows and cannot reference the new header). The `map` entry copying the source's key IS the back-reference and therefore the **at-most-once** guard, derived rather than declared twice; the button is dropped unless `button: true`, and both triggers share ONE generated create-from (a new `GenerateOnEvent.java.template` listener calls `<X>Generate.create(id)` and carries no mapping of its own). Details in the engine-intent guide's `event:` bullet.
Expand Down
2 changes: 2 additions & 0 deletions components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ generates:
map: { Customer: Customer }
defaults: { InvoiceDate: now }
items: { from: ProjectTimesheetItem, to: SalesInvoiceItem, map: { Description: Description } }
fromStatus: [2] # optional guard: the SOURCE statuses the action may run from
sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus after creation
sourceStatusOnRetire: 2 # optional INVERSE: where the SOURCE returns when the target is retired
```
Expand All @@ -517,6 +518,16 @@ status init and calculated fields fire. `sourceStatus:` flips the SOURCE to the
seed id once the target exists (proforma -> INVOICED) - a system write: no `-updated` re-fire, but
the source's `-transitioned` topic is published.

`fromStatus:` (#7068) guards the CLICK: the endpoint answers **409** and the button stops offering
itself unless the source stands in one of the listed statuses (seeded names or ids) - the `from:` of a
`transitions:` entry, spelled differently only because `from:` here already names the source ENTITY.
Declaring `sourceStatus:` and no `fromStatus:` IMPLIES the guard against exactly that status: a source
already standing where the completion hook put it has been generated from, and a second click used to
mint a second document (another invoice for an already-invoiced proforma, in the customer's hands). It
is refused where it cannot mean anything - a `page` scope, a source with no `function: EntityStatus`
relation, an event-only create-from (guard the moment with `event.when:`), and an allow-list that
contains the `sourceStatus` the action itself writes.

`event: { onTransition: <Source>, when: "Status == <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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.components.intent.generator;

import java.util.List;

import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport;
import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.GeneratesIntent;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.model.RelationIntent;
import org.eclipse.dirigible.components.intent.model.UsesIntent;

/**
* The from-status guard of a create-from (issue #7068) - the one rule, resolved once, for both
* halves of the action: the generated controller that refuses the run with 409, and the contributed
* button that stops offering it.
*
* <p>
* A {@code generates} used to be unconditional. With a {@code sourceStatus:} completion hook it
* flipped the source once the target existed - and then went on offering the same button on the
* flipped record, so a second click (or a second POST) minted a second document: another invoice
* for a proforma already INVOICED, in the customer's hands. The hook declared what "already done"
* looks like; nothing consulted it.
*
* <p>
* Two shapes, one guard. {@code fromStatus: [...]} is the explicit allow-list, the {@code from:} of
* a transition (spelled differently only because {@code from:} on a create-from already names the
* source ENTITY). Absent it, a declared {@code sourceStatus} IMPLIES the deny-list of exactly that
* status - the minimal refusal, and the one the author already declared: a source standing at its
* post-generation status has been generated from.
*/
public final class GeneratesGuardSupport {

private GeneratesGuardSupport() {}

/**
* A resolved guard over the SOURCE's {@code EntityStatus} foreign key: the record may run the
* create-from while its status is one of {@code allowed} (when an allow-list was authored) and
* while it is none of {@code blocked} (the implied one). Exactly one of the two is non-empty.
*
* @param statusProperty the source's status FK, PascalCase (e.g. {@code Status})
* @param allowed the authored allow-list of status seed ids, empty when the guard is implied
* @param blocked the implied deny-list of status seed ids, empty when an allow-list was authored
*/
public record Guard(String statusProperty, List<Integer> allowed, List<Integer> blocked) {

/** The guard as a Java boolean expression over an {@code int currentStatus} local. */
public String expression() {
StringBuilder terms = new StringBuilder();
for (Integer status : allowed.isEmpty() ? blocked : allowed) {
if (terms.length() > 0) {
terms.append(allowed.isEmpty() ? " && " : " || ");
}
terms.append("currentStatus ")
.append(allowed.isEmpty() ? "!= " : "== ")
.append(status);
}
return terms.toString();
}

/** The human half of the refusal, read after the action's name in the 409 body. */
public String text(String fromEntity) {
return allowed.isEmpty()
? "was already generated from this " + fromEntity + " (its status is the one the completion hook writes)"
: "is allowed only from status [" + join(allowed) + "]";
}

/** The status ids the guard names, for the javadoc of the generated controller. */
public String statuses() {
return join(allowed.isEmpty() ? blocked : allowed);
}

private static String join(List<Integer> ids) {
StringBuilder out = new StringBuilder();
for (Integer id : ids) {
if (out.length() > 0) {
out.append(", ");
}
out.append(id);
}
return out.toString();
}
}

/**
* The guard of a create-from, or {@code null} when it has none: nothing to check (no allow-list and
* no completion hook), no status column on the source to check it against, a {@code page}-scoped
* action, which acts on the view rather than on a record, or an event-driven one with no button at
* all - the guard is on the click, and an event trigger carries its own at-most-once guard.
*
* @param g the create-from
* @param statusProperty the source's status FK as the caller already resolved it (may be empty)
* @return the guard, or null
*/
public static Guard of(GeneratesIntent g, String statusProperty) {
if (statusProperty == null || statusProperty.isEmpty() || !"entity".equals(g.getScope()) || !g.hasButton()) {
return null;
}
if (g.hasFromStatus()) {
return new Guard(statusProperty, List.copyOf(g.getFromStatus()), List.of());
}
return g.getSourceStatus() == null ? null : new Guard(statusProperty, List.of(), List.of(g.getSourceStatus()));
}

/**
* The SOURCE's {@code function: EntityStatus} foreign key, PascalCase - read off this model for a
* local source, and off the owner's already-generated {@code .model} for a cross-model one (the
* status FK is author-named, so it is never guessed). Empty when the source declares none, or when
* the owner model is not resolvable here - the button then carries no guard and the generated
* controller's 409 stays the contract.
*
* @param g the create-from
* @param model the model being generated
* @param context the generation context (may be null outside a Generate)
* @return the status property, or an empty string
*/
public static String statusProperty(GeneratesIntent g, IntentModel model, IntentGenerationContext context) {
if (g.getFrom() == null || g.getFrom()
.isBlank()) {
return "";
}
if (g.isCrossModelSource()) {
UsesIntent uses = null;
for (UsesIntent candidate : model.getUses()) {
if (g.getFromUses()
.equals(candidate.getModel())) {
uses = candidate;
}
}
if (uses == null || context == null) {
return "";
}
CrossModelSupport.TargetInfo owner = CrossModelSupport.resolve(context, uses, g.getFrom());
return owner == null || owner.statusProperty() == null ? "" : owner.statusProperty();
}
for (EntityIntent entity : model.getEntities()) {
if (g.getFrom()
.equals(entity.getName())) {
for (RelationIntent relation : entity.getRelations()) {
if (relation.isEntityStatus()) {
return IntentNaming.pascalCase(relation.getName());
}
}
}
}
return "";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,18 @@ private static List<Map<String, Object>> buildGenerates(IntentModel model, Map<S
}
}
}
// The from-status guard (issue #7068): the click is refused with 409 unless the source
// stands in a status the action accepts. Its property is the source's status FK, which the
// completion hook resolved above when it declared one - otherwise resolve it here, because
// an authored `fromStatus:` needs it with no hook in sight.
String guardStatusProperty =
sourceStatusProperty.isEmpty() ? GeneratesGuardSupport.statusProperty(g, model, context) : sourceStatusProperty;
GeneratesGuardSupport.Guard guard = GeneratesGuardSupport.of(g, guardStatusProperty);
e.put("hasStatusGuard", guard != null);
e.put("guardStatusProperty", guard == null ? "" : guard.statusProperty());
e.put("guardStatusExpr", guard == null ? "" : guard.expression());
e.put("guardStatusText", guard == null ? "" : guard.text(g.getFrom()));
e.put("guardStatuses", guard == null ? "" : guard.statuses());
e.put("sourceStatusProperty", sourceStatusProperty);
e.put("sourceStatusValue",
g.getSourceStatus() == null || sourceStatusProperty.isEmpty() ? "" : String.valueOf(g.getSourceStatus()));
Expand Down
Loading
Loading