Skip to content

Sync ako/mxcli: page/OData/test-runner/microflow fixes (#795, #782, #802-#804, #791, #790) - #810

Merged
ako merged 19 commits into
mendixlabs:mainfrom
ako:main
Jul 31, 2026
Merged

Sync ako/mxcli: page/OData/test-runner/microflow fixes (#795, #782, #802-#804, #791, #790)#810
ako merged 19 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Syncs ako/mxcli into upstream: eight commits fixing seven reported issues, all reproduced first and verified against a real project.

Closes

Pages / describe (#795 + two follow-ups)

A Forms$MicroflowSource stores the microflow name in the nested Forms$MicroflowSettings; the DataGrid2 reader looked up a top-level Microflow key, got "", and returned no datasource. The describe formatter was correct all along — purely a read bug. Four readers each had their own copy of that lookup and disagreed; they now share one helper. Regression tests caught two more gaps: a Gallery using the legacy flat shape, and Forms$NanoflowSource missing entirely from the DataGrid2/Gallery switches.

A construct the codec engine cannot represent was logged and dropped while exec still printed Created page. The converter did return an error; widgetobj.ChildSerializer has no error channel (the existing TODO(shared-types)). Failures are now recorded and drained at the page/snippet write entry points, so the statement fails instead — ADR-0004's refuse-don't-drop.

A data container bound to a flow reported the flow as its context entity rather than the entity it returns. Not just cosmetic: EntityContext feeds the column and child-widget readers.

OData external entities (#782)

Two independent bugs, and the first is much bigger than the flag. entityFromGen recognised only DomainModels$OqlViewEntitySource, so any OData external entity read back with an empty Source and every remote field zeroed — describe external entity rejected it outright, and a read-modify-write rebuilt the entity without its source at all. The write path was always fine; it switches on e.Source, which the read never populated. Works under --engine legacy, whose parser handles all three flavours — a modelsdk-engine gap, and modelsdk is the default.

Separately, create or modify external entities reset CreateChangeLocally on every run. That is right for the capability flags, which the service's annotations describe, and wrong for a local modelling choice no OData contract mentions.

Test runner (#802 / #803 / #804)

Three reports, one teardown routine. The after-startup value was mis-parsed (DESCRIBE SETTINGS separates properties with commas; quotes were trimmed before punctuation), so the restore statement was unparseable, printed as a warning, and the project was left pointing at the microflow cleanup then deleted. Cleanup now returns its errors and fails the run. The generated MxTest module is dropped — but only when the run created it, since CREATE MODULE is idempotent and a pre-existing module is the user's. And the Security Level is left alone entirely: the after-startup microflow runs in an administrative context and is not subject to it, so forcing it OFF bought nothing while breaking custom-auth REST/OData services.

Microflow loops (#791, #790)

microflowObjectToGen had no case for Microflows$BreakEvent / ContinueEvent and its default returns nil, so the event was dropped while the SequenceFlow pointing at it was written — a DestinationPointer to a GUID that exists nowhere, exactly the KeyNotFoundException Studio Pro reports. Verified by dumping the microflow and checking every pointer resolves: 1 dangling before, 0 after. The same bug had already been fixed once for ErrorEvent; break and continue were missed. MDL051 — a check-time stopgap explicitly added "until the serialization is fixed" — is removed with it; note it only ever covered break, which is why the continue form reached a user as a corrupt project.

Loop boxes were sized from a measure that adds HorizontalSpacing on top of each element's full width, but that constant is a centre-to-centre pitch — over-counting a run of n activities by (n-1)*ActivityWidth. An example box goes 880 → 640 around 440px of content. Compound elements (IF/split, nested loop) keep the conservative measure: approximating their advance under-sized the box and pushed activities outside the frame, which is worse.

Already fixed, no change needed

#780 (call microflow in a loop without an error handler) is fixed on current main: ehType returns Rollback for microflows unless overridden, and the BSON is structurally identical with and without the clause, with zero dangling pointers. Worth closing with that note.

Not addressed

  • roundtrip for microflow with loops yields changes in positioning #790's other half — "position annotations sometimes change" is not reproducible: explicit @position round-trips exactly and three describe/exec cycles are byte-identical. The reporter noted it was inconsistent.
  • Authoring a nanoflow datasource on a pluggable widget still needs --engine legacy. modelsdk/gen/pages has no NanoflowSettings type and there is no Studio Pro sample to verify a hand-written shape against. It now fails loudly instead of silently dropping the binding.
  • Range validation on settings (a port of 0 or 999999 still parses as an integer).

Testing

make build, make test, make lint and make check-mdl pass. Every fix has a regression test verified failing against the pre-fix code, plus a repro fixture under mdl-examples/bug-tests/. Symptom-table rows added for each.

Two of these issues (#801, #802) had reporters who said they had their own fixes ready — worth checking before merging so the work isn't duplicated

claude and others added 19 commits July 30, 2026 22:23
…flowSettings (mendixlabs#795)

DESCRIBE PAGE emitted `datagrid g1 {` with no DataSource for a DataGrid2
bound to a microflow, so re-applying the describe output recreated the grid
with no data source at all. A `database from` source described correctly,
which made the loss look selective.

A Forms$MicroflowSource stores the microflow name in the nested
Forms$MicroflowSettings ("MicroflowSettings" -> "Microflow") — what the write
path emits and what Studio Pro stores. extractDataGrid2DataSource looked up a
top-level "Microflow" key, got "", and returned no datasource. The describe
formatter's `case "microflow"` branch was correct all along, so nothing was
ever wrong with the stored model or the write path: purely a read bug.

Four readers had their own copy of this lookup and disagreed — two read the
nested settings, one read the top-level key, one did both. They now share
microflowSourceRef / nanoflowSourceRef, which read the nested settings and
fall back to the legacy top-level key so older files still round-trip.

Two gaps the regression tests found while covering this:

- a Gallery whose source used the legacy flat shape was unreadable too: the
  correct reader it delegates to handles only the nested form, and the flat
  fallback in its own switch is not reached for a CustomWidget gallery
- Forms$NanoflowSource was missing entirely from the DataGrid2 and Gallery
  switches, so a Studio-Pro-authored nanoflow datasource described as nothing

Left alone deliberately: CustomWidgets$CustomWidgetNanoflowSource, a different
metamodel type whose Nanoflow key really is top-level, and the
Forms$MicroflowAction reads, which are actions rather than datasources.

Also corrects the datasource table in the create-page skill: it documented
`datasource: microflow Module.GetData()`, and the trailing `()` does not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
A construct the codec engine cannot represent — a nanoflow datasource on a
DataGrid2 is the reachable case — was logged and discarded while `mxcli exec`
still printed `Created page`. The grid landed with no data source at all.

The converter does return an error; it just had nowhere to go. Child content
is serialized through widgetobj.ChildSerializer, whose methods return BSON
with no error channel — the interface change flagged by the TODO in
mdl/backend/widgetobj/builder.go. Until that lands, the failure is recorded
in the modelsdk backend and drained by the page/snippet write entry points,
so the statement fails with the actionable message instead of the write
succeeding with data missing. ADR-0004 already required this: where the codec
path cannot reproduce a construct the backend refuses the op rather than
dropping it.

Authoring a pluggable nanoflow datasource still needs MXCLI_ENGINE=legacy,
which writes it correctly — verified, since the error message says so.

Also adds the missing `nanoflow` case to the DataGrid2 and Gallery describe
formatters. Reading such a source works after mendixlabs#795, but the formatter had
only database/microflow/parameter branches, so a legacy-written or
Studio-Pro-authored nanoflow grid still described with no DataSource.

Any new write entry point that builds pluggable widgets must drain too; the
symptom table records that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
…urns

A data container bound to a microflow or nanoflow reported the *flow* as its
context entity:

  datagrid g1 (DataSource: microflow Module.GetOrders) {
    -- Context: $currentObject (Module.GetOrders)   <- the flow, not the entity

The datasource reference was used verbatim. That is correct for a database
source, where the reference is the entity, and wrong for a flow source, where
it is the flow's own qualified name. Besides the misleading comment, the
context is passed down to the column and child-widget readers, which resolve
attributes against it.

The flow's return type is resolved instead, taking the entity from an Object
or List return. When the flow cannot be resolved, or returns a scalar, the
reference is kept — the previous behaviour, so nothing gets worse.

Resolution goes through ListMicroflows/ListNanoflows plus the container
hierarchy rather than GetRawUnitByName, which the flow builder's fast path
uses: that method is unimplemented on the modelsdk engine, which is the
default, so the list path is the one that actually resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
)

`alter entity X set allow_create_change_locally = true` reported success and
left the flag off. So did every other remote setting, and `describe external
entity` rejected the entity outright with "is not an external entity
(source: )".

The write path was never the problem: externalEntitySourceToGen switches on
e.Source and handles all three Rest$OData* flavours. The *read* filled none of
them — entityFromGen recognised only DomainModels$OqlViewEntitySource, so an
external entity came back with an empty Source and every remote field zeroed.
Setting a flag then wrote it onto a model that no longer knew the entity was
external, and the update rebuilt the entity without its source at all.

The legacy engine parses all three flavours (sdk/mpr/parser_domainmodel.go),
so this was a modelsdk-engine gap — and modelsdk is the default. Under
`--engine legacy` the same commands worked, which is a useful bisect signal.

Now mirrored for:

- Rest$ODataRemoteEntitySource — service, entity set, remote name, the
  capability flags, CreateChangeLocally and the remote key
- Rest$ODataEntityTypeSource — service, type name, IsOpen and the remote key
- Rest$ODataPrimitiveCollectionEntitySource — service

Updatable is deliberately left zero: the storage type has no such field
(updatability is per attribute via Rest$ODataMappedValue), there is no gen
accessor, and the writer does not emit one, so read and write stay symmetric.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
…y re-import

`create or modify external entities` reset "Allow creating and changing
objects locally" to false on every run, so the setting survived only until the
next import — the other half of mendixlabs#782.

applyExternalEntityFields stamps every field on both the create and the update
path. That is right for the capability flags (Countable / Creatable /
Deletable / SkipSupported / TopSupported), which are derived from the service's
capability annotations and should be refreshed from the metadata. It is wrong
for CreateChangeLocally, which no OData contract describes: it is a local
modelling choice, so the top-level branch now leaves it alone. A newly imported
entity arrives zero-valued, which is Mendix's default.

The entity-type branch still clears it, since that storage type has no such
field — a re-import that reclassifies an entity as a derived type would
otherwise leave a stale value in the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
…endixlabs#791)

A loop containing a split whose branch does `continue` wrote successfully and
could not be opened in Studio Pro:

    System.Collections.Generic.KeyNotFoundException: The given key
    '806fca46-5c4b-46f8-a890-4d24dd29c24f' was not present in the dictionary.

microflowObjectToGen had no case for Microflows$BreakEvent or
Microflows$ContinueEvent, and its default branch returns nil. The event object
was therefore dropped at serialization while the SequenceFlow pointing at it
was written, leaving a DestinationPointer to a GUID that exists nowhere in the
document — the key Studio Pro cannot resolve. Confirmed by dumping the written
microflow and checking every *Pointer against the object IDs: one dangling
pointer, and no ContinueEvent anywhere. The legacy engine serializes both, so
this only affected the default engine.

The identical bug had already been found and fixed for Microflows$ErrorEvent,
whose case still carries the comment explaining it. Break and continue were
missed.

MDL051 goes with it. That rule rejected a conditional `break` at check time
and pointed users at a guard-variable workaround, explicitly "until the
serialization is fixed" — it now is, and keeping the rule would reject valid
MDL. Note it only ever covered `break`: `continue` had no guard, which is how
this reached a user as a corrupt project. Its negative fixture becomes a
positive one, and its test now asserts the pattern is accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
…endixlabs#803/mendixlabs#804)

Three defects in one teardown, which together left a tested project
permanently mutated.

quotes before trailing punctuation, and DESCRIBE SETTINGS separates properties
with commas, so

    AfterStartupMicroflow = 'MyFirstModule.ASU_Startup',

parsed to `MyFirstModule.ASU_Startup',` and the restore statement became

    ALTER SETTINGS MODEL AfterStartupMicroflow = 'MyFirstModule.ASU_Startup','

which does not parse. The failure printed as a warning, so the run looked
clean while the project was left pointing at MxTest.TestRunner — the microflow
cleanup then deleted. Trailing `,;` now comes off before the quotes, the value
is re-emitted through a helper that doubles embedded quotes (Mendix's escape,
never backslashes), and cleanup returns its errors instead of printing them:
a failed restore now fails the run and names what was left changed.

so an empty module accumulated on every run. It now drops the module — but
only when the run created it. CREATE MODULE is idempotent, so a project that
already had its own MxTest module would otherwise have had it deleted; that
case still removes just the generated microflow. Cleanup also re-checks that
the module is present, so a run that failed before injection landed does not
report a spurious cleanup failure.

hardcoded PRODUCTION, silently changing projects that legitimately run at
another level, and breaking any project whose published REST/OData services
use custom authentication ("App security is off, but custom authentication is
enabled for this service"). The after-startup microflow runs in an
administrative context and is not subject to the Security Level, so this
bought nothing. It is gone; the setting is the project's business.

What cleanup must restore is now captured in a projectState before the first
mutation, and the setup/cleanup statements are built by pure functions, so the
restore is testable without a project or Docker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
…ies (mendixlabs#790)

A loop box was drawn far wider than its contents — 880px around three
activities spanning 440px, leaving ~290px of empty area inside the frame.

measureStatements sums each element's full width and adds HorizontalSpacing
between them. But HorizontalSpacing is a centre-to-centre pitch: the builder
centres each activity on posX and advances by exactly that, so counting it on
top of each width over-measures a run of n simple activities by
(n-1)*ActivityWidth — 240px for three. The loop width is derived from that
measure, so the box inherited the error.

Loop sizing now uses measureStatementsSpan, which returns the true extent
(n-1)*HorizontalSpacing + ActivityWidth for a run of simple activities. The
example above goes from 880 to 640 (the body occupies 90..530 inside it), and
nested loops shrink at both levels.

A compound element (IF/split, nested loop) advances posX by merge geometry
this function cannot reproduce without duplicating the builder. An earlier
attempt to approximate it under-sized the box and pushed the trailing activity
30px outside the frame — worse than a box that is merely too wide — so those
runs fall back to the conservative measure and are unchanged. Verified by
checking that every child of a LoopedActivity lies within its box, across
loop-with-if, nested-loop, single-statement and multi-statement bodies.

Not addressed: the other half of mendixlabs#790, "position annotations sometimes
change". Explicit @position annotations round-trip exactly, and three
successive describe/exec cycles produce byte-identical output, so there is
nothing reproducible to fix here — the reporter also noted it was
inconsistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XR649rKk68z6gBpngu6MA
fix: TraceOps findings — DYNAMICTEXT content, ListView PageSize (#9/#10/#17) + doc-comment & combobox-association follow-ups
fix(pages): read a pluggable widget's flow datasource, and stop dropping children silently
…-entity-782

# Conflicts:
#	.claude/skills/fix-issue.md
…eanup-802-804

# Conflicts:
#	.claude/skills/fix-issue.md
…-791-790

# Conflicts:
#	.claude/skills/fix-issue.md
…eanup-802-804

# Conflicts:
#	.claude/skills/fix-issue.md
…-791-790

# Conflicts:
#	.claude/skills/fix-issue.md
@ako
ako merged commit b344f99 into mendixlabs:main Jul 31, 2026
2 checks passed
This was referenced Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants