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
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` /
- **Parser hardening.** A wrong-typed scalar (e.g. an unquoted brace recipient `to: {member.email}`, which YAML parses as an object) now surfaces as a clean `IntentValidationException` issue with a helpful message instead of a raw 500 Gson error that wedged the editor. `IntentParserTest` covers it.
- **Externalized AI system prompt.** Moved from an inline string to `intent-assistant-guide.md` (classpath resource, fail-fast load), corrected to the full current schema incl. the glue catalog + `businessKey`/`businessKeyStrategy`, and restored the propose-the-whole-file tool contract the draft had dropped.
- **Settlements re-allocate a corrected payment (#6818).** The payment spread handler used to bind the payment's bare create topic only, so a payment booked for the wrong amount and corrected afterwards - or created incomplete and completed later - was never re-allocated and the invoice kept the original settled figure. It is now emitted once per bound payment event (create + `-updated`) from its own glue collection, **`settlementListeners`** - the `settlements` collection still drives the one-per-settlement `<Name>OnInvoice` delegate, and a second collection is what lets the two templates fan out differently while sharing one descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions do). Note the two traps a new glue collection carries: it needs a `case` in `GlueGenerator` (Java - the only generator since #6707; the old `generateUtils.js` twin is gone) or it renders whole-model with raw `${...}` placeholders, and `GlueGenerator.copy` is a per-key allow-list, so a new descriptor key that is not listed there never reaches the template. The handler itself was already a recompute of the payment's *unallocated* balance (re-delivery is a no-op by construction); it now also **releases** the excess - newest allocation first, through the junction repository - when the payment is corrected below what it already covers, so the recompute converges in both directions. Two boundaries of that release: the CREATE handler never releases - a create event is the first word about a payment, so a negative pot there can only be a DELAYED create racing a correction the updated handler already allocated, and releasing on the stale payload would undo it (#6865). And a corrected MATCH column (the payment re-filed under another Customer) is invisible to the amount-based recompute (`pot - allocated == 0`), so the payment's match columns are grouping keys and a third listener on the payment's `-rekeyed` topic releases everything and re-allocates from the STORE - both re-key notices run the same store-driven recompute, so delivery order cannot matter; emitted only for a LOCAL payment, since a cross-model payment's DAO belongs to the owner model and a projection has no repository to re-read (#6864).
- **What a Duplicate does NOT copy (`duplicable` object form, #7358).** `duplicable: true` cloned every ordinary user field of the header, so a copied invoice kept the source's `date`, `due` and `taxEventDate` - "same invoice as last month" opened dated last month, and the module could not fix it on its own side: a `calculatedActionOnCreate` fills an EMPTY value and respects a present one by contract, which is exactly what makes the copied value stick. The key now also takes `{ defaults: {...}, reset: [...] }` - `reset` drops a field from the clone so the create path fills it as it would on a hand-made document, `defaults` writes a constant, with the same `now` token and the same field-shape rendering `generates.defaults` has (`date` -> `YYYY-MM-DD`, `month` -> `YYYY-MM`, `week` -> `YYYY-Www`). The shorthand is normalized to the empty object on the RAW tree (`IntentParser.normalizeDuplicable`, the `expandUniqueShorthand` precedent) so one typed class carries both forms and the unknown-key walk sees the two keys; `EntityIntent.duplicable` is therefore a `DuplicateIntent`, not a `Boolean`. Both halves reach the document template as **structured** `.edm` attributes (`duplicateReset`, `duplicateDefaults` in `STRUCTURED_ATTRIBUTES` and in `transform-edm.js`'s `ENTITY_STRUCTURED`) - entity metadata a flat attribute cannot carry is lost on the next modeler save (#6826), which here would silently put the copied dates back. Refused at parse, each because accepting it would be silent: a name that is neither a field nor a to-one of the entity, one of the built-in drops (identity, `number:`, `function: EntityStatus`, `readOnly`, `aggregate`), the same name in both lists, `now` on a property that is not a date/month/week, and a `reset` of a **required** field with no `defaultValue` and no create-time rule - that one would make every duplicate fail on the server's own "field is required". The `now` value renders from the LOCAL calendar fields in `todayAs`, never `toISOString()`: east of Greenwich that is yesterday after the evening cut-over. Out of scope and still true: the copy is client-side and not atomic - a failed line POST leaves a half-copied draft.
- **CI runs on Corretto 24** (compile target stays 21); the integration-test fork gets `-Xmx6g`. (Root-level change; recorded here because it landed alongside the intent work.)

**Cross-artefact field naming:** the `.form` control `model` (and control `id`) bind to the entity property, so they use `IntentNaming.pascalCase` to match the EDM property names (`loanedOn` -> `LoanedOn`). The `.report` references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing.
4 changes: 3 additions & 1 deletion components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ entities:

Entity-level extras: `order: [Id, Product, Quantity, ...]` sequences form controls/list columns;
`duplicable: true` adds a Duplicate button on a document (clones header + items through the normal
create path); `imports: |` injects Java import lines into the generated repository (pairs with
create path), and its object form says what the copy must NOT carry over - `duplicable: { defaults: {
date: now }, reset: [due, taxEventDate] }`, where `reset` hands a field back to the entity's own
create-time rule and `defaults` writes a constant (`now` is today in the field's own shape); `imports: |` injects Java import lines into the generated repository (pairs with
calculated actions); `aggregate: true` on a document master's numeric field keeps it equal to the
sum of the items' same-named field (the totals footer).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,18 @@ private static EdmDocument buildDocument(IntentGenerationContext context, Intent
// current document (header + line items) into a new draft (see the document template).
if (entity.isDuplicable()) {
entityMap.put("duplicable", "true");
// What the copy must NOT carry over from the source (#7358): the fields handed back
// to the entity's own create-time rule, and the constants written into the clone.
// Without them every ordinary user field rides along, so "same invoice as last
// month" opens dated last month, due last month, with last month's tax event.
List<String> resets = duplicateResets(entity);
if (!resets.isEmpty()) {
entityMap.put("duplicateReset", resets);
}
List<Map<String, Object>> constants = duplicateDefaults(entity);
if (!constants.isEmpty()) {
entityMap.put("duplicateDefaults", constants);
}
}
// Chat items: render the line-items pane as a conversation thread instead of the editable
// table. Resolve which child property is the message body (and the optional internal
Expand Down Expand Up @@ -3523,6 +3535,101 @@ private static Integer defaultLength(String dataType) {
* out deterministically in a grid so re-generation is byte-stable.
*/
@SuppressWarnings("unchecked")
/**
* The generated property names a Duplicate drops from the cloned header, in authored order. Each is
* handed back to the create path, which fills it exactly as it would on a hand-made document (a
* {@code calculatedActionOnCreate}, a {@code defaultValue}).
*
* @param entity the duplicable document master
* @return the PascalCase property names, never null
*/
private static List<String> duplicateResets(EntityIntent entity) {
List<String> resets = new ArrayList<>();
for (String name : entity.getDuplicable()
.getReset()) {
if (notBlank(name)) {
resets.add(IntentNaming.pascalCase(name.trim()));
}
}
return resets;
}

/**
* The constants a Duplicate writes into the cloned header, as {@code {name, shape, js}} entries in
* authored order. {@code shape} is {@code date} / {@code month} / {@code week} for the {@code now}
* token - today in the field's own shape, rendered by the document page's {@code todayAs} helper
* against the LOCAL clock - and {@code literal} otherwise, where {@code js} carries the value
* already coerced to the property's type as a JavaScript literal.
*
* @param entity the duplicable document master
* @return the entries, never null
*/
private static List<Map<String, Object>> duplicateDefaults(EntityIntent entity) {
List<Map<String, Object>> defaults = new ArrayList<>();
for (Map.Entry<String, String> assignment : entity.getDuplicable()
.getDefaults()
.entrySet()) {
String name = assignment.getKey();
String value = assignment.getValue();
if (!notBlank(name) || !notBlank(value)) {
continue;
}
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", IntentNaming.pascalCase(name.trim()));
String type = duplicateDefaultType(entity, name.trim());
if ("now".equals(value.trim())) {
entry.put("shape", "month".equals(type) || "week".equals(type) ? type : "date");
entry.put("js", "");
} else {
entry.put("shape", "literal");
entry.put("js", duplicateLiteral(value.trim(), type));
}
defaults.add(entry);
}
return defaults;
}

/**
* The authored type of the named field, or {@code integer} for a to-one relation (a default on a
* relation assigns its raw foreign key). Blank when the name resolves to neither - the parser has
* already refused that, so generation never has to.
*/
private static String duplicateDefaultType(EntityIntent entity, String name) {
for (FieldIntent field : entity.getFields()) {
if (name.equalsIgnoreCase(field.getName())) {
return field.getType() == null ? ""
: field.getType()
.toLowerCase(Locale.ROOT);
}
}
for (RelationIntent relation : entity.getRelations()) {
if (name.equalsIgnoreCase(relation.getName())) {
return "integer";
}
}
return "";
}

/**
* A literal {@code duplicable.defaults} value as the JavaScript source the document page assigns: a
* number for a numeric property, {@code true} / {@code false} for a boolean, a quoted string
* otherwise. The property's declared type decides, not the value's shape - a string field holding
* {@code "01"} must stay the string it was authored as.
*/
private static String duplicateLiteral(String value, String type) {
switch (type) {
case "integer":
case "long":
case "double":
case "decimal":
return value;
case "boolean":
return Boolean.toString(Boolean.parseBoolean(value));
default:
return STRUCTURED_JSON.toJson(value);
}
}

private static String renderEdmXml(EdmDocument document) {
Map<String, Object> body = (Map<String, Object>) document.modelJson.get("model");
List<Map<String, Object>> entities = (List<Map<String, Object>>) body.get("entities");
Expand Down Expand Up @@ -4011,9 +4118,9 @@ private static String sanitizeId(String raw) {
* {@code transform-edm} rebuilds into {@code uniqueConstraints}. Emitting it here too would write
* it twice and round-trip it as a duplicate.
*/
private static final Set<String> STRUCTURED_ATTRIBUTES =
Set.of("rollupGuard", "checks", "labelParts", "aggregateKeys", "groupingKeys", "relatedEntities", "scopedCalendars",
"lifecycleStatusNameList", "lookupColumns", "languages", "widgets", "customActionLabels", "processTaskLabels");
private static final Set<String> STRUCTURED_ATTRIBUTES = Set.of("rollupGuard", "checks", "labelParts", "aggregateKeys", "groupingKeys",
"relatedEntities", "scopedCalendars", "lifecycleStatusNameList", "duplicateReset", "duplicateDefaults", "lookupColumns",
"languages", "widgets", "customActionLabels", "processTaskLabels");

/**
* Compact, non-HTML-escaping JSON for the structured {@code .edm} attributes. Compact so the value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.model;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* What a document's built-in <b>Duplicate</b> action does to the copied header, beyond the drops it
* has always made (identity, audit columns, status, document number, read-only and aggregate
* fields, which are not authorable).
*
* <p>
* Authored as the object form of the entity's {@code duplicable} key; the shorthand
* {@code duplicable: true} is the empty object, so an entity that says nothing keeps today's
* behaviour exactly - every ordinary user field is copied.
*
* <pre>
* duplicable:
* defaults: { date: now } # constants written into the clone
* reset: [due, taxEventDate] # dropped, so the entity's own create-time rule refills them
* </pre>
*
* <p>
* The two keys compose and never overlap: {@code reset} is for a field that HAS a create-time rule
* (a {@code calculatedActionOnCreate}, a {@code defaultValue}) and must be handed back to it - a
* copied value would be respected by that rule and stick; {@code defaults} is for a field that has
* none, where the copy needs a value stated here. {@code now} renders today in the field's own
* shape, the same token and the same rendering {@code generates.defaults} uses.
*/
public class DuplicateIntent {

/**
* Constants written into the cloned header after the resets, by the entity's own field / to-one
* relation name. {@code now} is today in the field's shape ({@code date} -> {@code YYYY-MM-DD}, a
* {@code month} field -> {@code YYYY-MM}, a {@code week} field -> {@code YYYY-Www}); any other
* value is a literal coerced to the property's type.
*/
private Map<String, String> defaults = new LinkedHashMap<>();

/**
* The entity's own field / to-one relation names dropped from the clone, so the create it posts
* fills them exactly as it would on a hand-made document.
*/
private List<String> reset = new ArrayList<>();

/** The constants written into the clone, keyed by authored property name; never null. */
public Map<String, String> getDefaults() {
return defaults == null ? new LinkedHashMap<>() : defaults;
}

public void setDefaults(Map<String, String> defaults) {
this.defaults = defaults == null ? new LinkedHashMap<>() : defaults;
}

/** The authored property names dropped from the clone; never null. */
public List<String> getReset() {
return reset == null ? new ArrayList<>() : reset;
}

public void setReset(List<String> reset) {
this.reset = reset == null ? new ArrayList<>() : reset;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,14 @@ public class EntityIntent {
* draft and opens it. The clone creates through the normal REST create path, so the number
* ({@code calculatedActionOnCreate}), the initial status ({@code init}) and calculated fields are
* reassigned by the server. Absent (the default) → no Duplicate action.
*
* <p>
* Authored either as the shorthand {@code duplicable: true} or as the object form
* {@code duplicable: { defaults: {...}, reset: [...] }}, which says which fields the copy must NOT
* carry over from the source (the invoice's date, due date and tax-event date). The parser
* normalizes the shorthand to an empty object, so both arrive here as this type.
*/
private Boolean duplicable;
private DuplicateIntent duplicable;
/**
* Optional explicit ordering of the generated UI controls (form inputs, list columns, detail rows)
* by property name - fields and to-one relations interleaved, in the given order. Names match the
Expand Down Expand Up @@ -618,10 +624,10 @@ public void setMultilingual(Boolean multilingual) {
* ({@code duplicable: true}).
*/
public boolean isDuplicable() {
return Boolean.TRUE.equals(duplicable);
return duplicable != null;
}

public Boolean getDuplicable() {
public DuplicateIntent getDuplicable() {
return duplicable;
}

Expand All @@ -641,7 +647,7 @@ public void setLocksWithMaster(Boolean locksWithMaster) {
this.locksWithMaster = locksWithMaster;
}

public void setDuplicable(Boolean duplicable) {
public void setDuplicable(DuplicateIntent duplicable) {
this.duplicable = duplicable;
}

Expand Down
Loading
Loading