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
3 changes: 2 additions & 1 deletion components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ Semantics worth knowing:
- **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via the composition child, `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message.
- **`resolves:` = the effective-dated register lookup (#6712).** The enterprise shape with no declarative form before it: a register says "X applied to Y from A to B" (a vehicle assignment, a price list, a contract in force, an org assignment), a record carries the match key(s) and a date, and a to-one must be filled from the row whose period covers that date. Nothing else in the DSL reaches it - `dependsOn` is a UI-time copy with equality matching only, a `decision` condition is a single comparison, and `setField` writes constants - so every application hand-wrote the same delegate. Authored as `{ event: { onCreate|onUpdate: <Record>, when? }, set: <to-one>, from: <Register>, match: { <registerProperty>: <recordProperty>, ... }, between: { start?, end?, value }, outcome?: <string field>, found?/notFound?/ambiguous?: { setStatus } }`; `ResolveIntent` -> `GlueIntentGenerator.buildResolves` -> the `resolves` glue collection -> `Resolve.java.template`, a `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class, and that is the point of the construct:** exactly one covering row fills the relation, NO covering row and MORE THAN ONE covering row both leave it unset (an automation that silently picks one of two candidates is worse than none - the ambiguous register goes back to a human). Each outcome may route the record by `setStatus` (seed id or seeded name, resolved by `StatusSymbolResolver` like every other status site), and the attempt is **observable**: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field of the record - queryable, filterable in a list view, and readable by a process `decision` - and the handler logs the keys and the date it checked. **Decisions worth keeping:** the value copied is derived, not authored - the register must carry exactly ONE to-one to the same target as `set:`, and zero or two is a validation error rather than a guess (the same refusal, one altitude up); a record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op; the write is a single targeted `updateProperties` of the relation + the outcome + the status, so no `-updated` re-fires and no concurrent write to another column is reverted; period bounds are optional on either side (open-ended = still valid), the end is INCLUSIVE, and a date-only bound covers its whole day (the generated `millis`/`endExclusive` helpers put a `LocalDate` and an `Instant` on one epoch-milli axis, UTC). v1 is same-model (`from:` must be declared here) and binds to `onCreate`/`onUpdate` only - `onDelete` is refused, there is nothing left to fill. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard.
- **Every DERIVED write is targeted (document totals, `rollups:`, `aggregates:`) — the last member of the lost-update family.** A recompute reads a row, changes the one or two columns it computes, and persists. Persisting the WHOLE row silently reverts any concurrent write to another column of that row: the trigger `ProcessId` variant was fixed in #6226 and the workflow setter/writer variant in #6306, and the recompute variant was live-reproduced against a roll-up (REST-create a parent, PUT another column immediately after → 200, but a re-read shows the OLD value; the recompute had read the row before the PUT and wrote its stale snapshot after it). All three recompute sites now write only what they computed: `Repository.recalculate(Object)` collects the document totals into a map and calls the base `super.updateProperties` (no gate checks, no `-updated` — exactly the previous `super.update` semantics minus the merge); `Rollup.java.template` and `Aggregate.java.template` collect each recomputed column into a `derived` map and persist through the generated **`updateDerived(id, values)`**, which routes through `updateProperties` (so a `checks:` entity still runs its gate and a labelled entity still refreshes its `Name`) and then re-publishes `<project>-<perspective>-<entity>-updated` — the event contract the old full-row `update()` provided, which TRANSITIVE roll-ups above the row depend on. Two invariants when touching these: a column assigned in the recompute must also be put into `derived` (a capacity roll-up writes count + balance + status), and an EMPTY `derived` map means nothing is persisted, so the map is what the emission oracle asserts. Covered by the `IntentEmissionCoverageIT` derived-write assertions (Bill document totals, `ClaimLineClaimRollupOnCreate`, `LedgerTotalAggregateOnCreate`). **The reverse direction had the same hole (#6822):** the master's resum was wired only to the item's FULL write paths (`save`/`update`/`delete`), so a line written by a TARGETED primitive - a workflow `setField`, any glue `updateProperty`/`updateProperties`/`updateDerived`, or the event-suppressed `updateWithoutEvent` - moved the line and left the header displaying, printing and POSTING a total that did not equal the sum of its lines. Those paths now resum too, guarded on the columns actually written (an aggregated column, or the FK - which MOVES the line, so both the document it joined and the one it left are resummed), so a status hop still costs nothing extra. It cannot recurse: the master's `recalculate` persists through the BASE targeted write.
- **Re-parenting is a two-sided event, and `-rekeyed` is the whole mechanism (#6819).** A row whose grouping column moves - an `aggregates:` key, or a `rollups:` child's `via` FK - leaves one group and joins another, and the ordinary events name only the group it belongs to NOW: `-updated` carries the written row, so the group it LEFT is named by nothing and kept the row's contribution forever (a cost centre reassigned by a workflow step; a `sum` roll-up whose parent FK an ordinary edit re-points). The repair is one dedicated topic, `<project>-<perspective>-<entity>-rekeyed`, which **only** the generated aggregate / roll-up handlers subscribe to - so a write can signal them without re-publishing `-updated` and spuriously re-firing every reaction. Three parts, and all three are needed: (1) the entity's `.model` carries **`groupingKeys`** - the union of every aggregate key over it AND every roll-up `via` FK whose child it is (`EdmIntentGenerator`; it used to be `aggregateKeys`, aggregates-only, which is why re-parenting a roll-up child was invisible); (2) the DAO compares those columns before/after on **both** write paths - the full-row `update()` publishes the PREVIOUS row (the group it moved into is recomputed off `-updated` like any other change), and `updateProperties` - the targeted primitive every workflow setter, `resolves:` and task-form writer goes through, which publishes no `-updated` at all - publishes the previous row AND the written one, since on that path neither side has an event otherwise; (3) both handler families bind it, the aggregate as its `OnRekey` variant and the roll-up as `RollupOnRekey`. Each handler recomputes the group the PAYLOAD names, from the store, so one class repairs either side and re-delivery converges. The publish is gated on a key having actually moved, so a normal edit costs nothing extra and the cascade still terminates at rest.
- **`checks: kind: guard` = a precondition over a keyed `aggregates:` sum, with three outcomes.** The negative-stock / credit-limit / remaining-allowance shape: `aggregate:` names an `aggregates:` entry whose `of` is THIS entity (v1 self-referential), and the post-state is checked against `minimum:` (default 0). The sum is recomputed SYNCHRONOUSLY from the guarded entity's own store for the incoming row's key-tuple, excluding this row on update, then the incoming value is added - deliberately NOT read from the async-maintained aggregate target, so the decision cannot race the handler. Consequence worth remembering: the guard and the materialised aggregate are two independent computations of the same sum, and the guard is the authoritative one - do not "optimise" it into a target read. `enabledBy: <CONFIG_KEY>` wraps the whole guard in a `Configurations.get(key) == "true"` gate (a tenant-level business toggle). Emitted by `EdmIntentGenerator.buildChecks` (keys + `sumField` + `pk` + `minimum` + `enabledBy` + `outcome`) → `ModelParameterProcessor` splits `guardChecks` out → the DAO's `#aggregateGuardCheck` macro at both the save and update sites. **`outcome:` decides what a violation DOES**, and each non-default outcome carries its own companion key (parser-validated - a companion belonging to another outcome is an ERROR, since the write would look guarded and do nothing):
- **`block`** (the default) - throws `ValidationException`, so the REST write fails with 4xx and nothing is persisted.
- **`task`** + **`marker: <boolean field>`** - does NOT fail the write. It stamps the marker (`false` on violation, `true` when it holds) as the BRANCH INPUT a process `decision` reads to route the record to a hold/review step. The division of labour is deliberate and must stay documented as such: this keyword stamps a flag, the process decides what the flag means - the DSL neither creates nor routes to a task.
Expand Down Expand Up @@ -555,7 +556,7 @@ Every action below has a real SDK surface to generate against, so none of this n
rollups:
- { name: memberLoanCount, entity: Loan, via: member, field: loanCount } # Member.loanCount = #Loans whose `member` FK = that Member
```
→ two `gen/events/<module>/<Name>RollupOn{Create,Delete}.java` `@Listener`s on the child's create/delete topics that recompute the affected parent's count via a typed `Criteria` (`findAll(Criteria.create().eq("<Fk>", entity.<Fk>)).size()`) and write it back. Recompute-on-event (self-healing); **eventually consistent, not transactionally exact** under heavy concurrency. **Gap:** no `where` filter (counts all children), and re-parenting on child update isn't tracked (only create/delete). **`op: sum`** keeps a decimal sum of the child `of` field (+ optional `capacity`/`balance`/`status` for payment-settlement); **`op: latest`** copies the `of` value of the child row with the greatest `by` date/timestamp onto the parent field (create/update/delete handlers; parent field must match `of`'s type; empty child set → null) — the "keep the parent's rate equal to the newest child rate" shape (currencies `Currency.rate` ← latest `CurrencyRate`). `RollupAggregates` in the pipeline tracks the max-`by` row type-agnostically (`var` + `Objects.equals`).
→ `gen/events/<module>/<Name>RollupOn{Create,Delete,Rekey}.java` `@Listener`s on the child's create/delete/**rekey** topics that recompute the affected parent's count via a typed `Criteria` (`findAll(Criteria.create().eq("<Fk>", entity.<Fk>)).size()`) and write it back. Recompute-on-event (self-healing); **eventually consistent, not transactionally exact** under heavy concurrency. The rekey handler is what repairs a **re-parented** child - see the rekey bullet in the conventions above. **Gap:** no `where` filter (counts all children). **`op: sum`** keeps a decimal sum of the child `of` field (+ optional `capacity`/`balance`/`status` for payment-settlement); **`op: latest`** copies the `of` value of the child row with the greatest `by` date/timestamp onto the parent field (create/update/delete handlers; parent field must match `of`'s type; empty child set → null) — the "keep the parent's rate equal to the newest child rate" shape (currencies `Currency.rate` ← latest `CurrencyRate`). `RollupAggregates` in the pipeline tracks the max-`by` row type-agnostically (`var` + `Objects.equals`).
10. **Dynamic user-task assignment** — `assignee: { path: member.branch.manager, fallback: manager }`, resolver-driven (extends the existing user-task glue). **(v1 implemented — see the resolver-path bullet in the conventions above.)**

### Guardrails (so this doesn't become the MDE expressiveness trap)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,13 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
rollups.add(rollupEntry(base, className + "RollupOnUpdate", "-updated"));
}
rollups.add(rollupEntry(base, className + "RollupOnDelete", "-deleted"));
// Re-parenting: the child's create/update/delete events all name the parent it belongs to NOW,
// so the parent it moved AWAY from is named by no event of theirs and kept the child's
// contribution forever (#6819). The DAO publishes the row on "-rekeyed" whenever a grouping
// column moves - the previous row from the full-row write, both the previous and the written
// one from the targeted writes that publish no "-updated" at all. It is the same recompute
// keyed on the payload's FK, so one handler repairs whichever side the payload names.
rollups.add(rollupEntry(base, className + "RollupOnRekey", "-rekeyed"));
}
return rollups;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,32 +605,37 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal
entityMap.put("labelExpression", entity.getLabel());
entityMap.put("labelParts", buildLabelParts(entity, byName));
}
// An entity that is the SOURCE of an aggregate carries its grouping keys (the union across
// every aggregate over it) plus its pk, so the DAO can notice on update that a key MOVED and
// let the aggregate repair the tuple the row left behind. Recomputing the tuple it moved
// INTO is already event-driven; the tuple it left has no event of its own.
List<Map<String, String>> aggregateKeys = new ArrayList<>();
Set<String> seenAggregateKeys = new LinkedHashSet<>();
// An entity whose rows are GROUPED by something maintained asynchronously - the keys of every
// aggregate over it, and the parent FK of every roll-up it feeds - carries those columns plus
// its pk, so the DAO can notice that a write MOVED the row between groups. A move has two
// sides and only one of them has an event of its own, so the DAO publishes the row on the
// dedicated "-rekeyed" topic for the aggregate / roll-up handlers to repair the other.
List<Map<String, String>> groupingKeys = new ArrayList<>();
Set<String> seenGroupingKeys = new LinkedHashSet<>();
if (model.getAggregates() != null) {
for (AggregateIntent a : model.getAggregates()) {
if (a.getOf() == null || !a.getOf()
.equals(entity.getName())) {
continue;
}
for (String key : a.getBy()) {
String fk = IntentNaming.pascalCase(key);
if (seenAggregateKeys.add(fk)) {
Map<String, String> pair = new LinkedHashMap<>();
pair.put("key", fk);
aggregateKeys.add(pair);
}
addGroupingKey(groupingKeys, seenGroupingKeys, key);
}
}
}
if (!aggregateKeys.isEmpty()) {
entityMap.put("aggregateKeys", aggregateKeys);
FieldIntent aggregatePk = primaryKeyOf(entity);
entityMap.put("aggregateSourcePk", aggregatePk == null ? "Id" : IntentNaming.pascalCase(aggregatePk.getName()));
// A roll-up groups its child rows by ONE column - the `via` relation's FK - and re-parenting a
// child is the ordinary way that column moves. Without it here, only aggregate sources were
// tracked and a re-parented roll-up child left its former parent's total stale forever (#6819).
for (RollupIntent rollup : model.getRollups()) {
if (rollup.getVia() != null && entity.getName()
.equals(rollup.getEntity())) {
addGroupingKey(groupingKeys, seenGroupingKeys, rollup.getVia());
}
}
if (!groupingKeys.isEmpty()) {
entityMap.put("groupingKeys", groupingKeys);
FieldIntent groupingPk = primaryKeyOf(entity);
entityMap.put("groupingSourcePk", groupingPk == null ? "Id" : IntentNaming.pascalCase(groupingPk.getName()));
}
List<Map<String, Object>> checkMaps = buildChecks(entity, byName, model.getAggregates());
if (!checkMaps.isEmpty()) {
Expand Down Expand Up @@ -2340,6 +2345,23 @@ private static Map<String, Object> relationLink(String ownerEntity, RelationInte
return link;
}

/**
* Records one grouping column of an entity, de-duplicated: an aggregate key and a roll-up's
* {@code via} FK can name the same relation, and the DAO must compare it once.
*
* @param keys the collected grouping columns
* @param seen the property names already collected
* @param name the authored relation / field name
*/
private static void addGroupingKey(List<Map<String, String>> keys, Set<String> seen, String name) {
String property = IntentNaming.pascalCase(name);
if (seen.add(property)) {
Map<String, String> pair = new LinkedHashMap<>();
pair.put("key", property);
keys.add(pair);
}
}

/** The target entity's primary-key field, or null when the target is unknown or has no PK. */
private static FieldIntent primaryKeyOf(EntityIntent entity) {
if (entity == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2391,6 +2391,10 @@ rollups:
`entity` is the child being counted, `via` is the child's to-one relation pointing at the parent, and
`field` is the integer field on the **parent** that holds the count.

**Re-parenting is handled.** Moving a child to another parent - an edit of its `via` relation, by a user
or by a process step - leaves BOTH parents' totals right: the one that received the child and the one it
left. Nothing to declare.

**Sum + balance + status (payment settlement).** With `op: sum` the roll-up keeps `field` equal to the
sum of the children's `of` field. Add `capacity` (a numeric parent field the sum is measured against)
to also maintain a `balance` field (= `capacity − sum`) and set a `status` relation to `statusWhenFull`
Expand Down
Loading
Loading