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
10 changes: 10 additions & 0 deletions .claude/skills/fix-issue.md

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions .claude/skills/mendix/analyze-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,72 @@ Gotchas:
- A spike in "Executing N database synchronization command(s)" on an *unchanged* model
is a red flag (see the `create or modify` data-loss class of bug).

### Turning up one subsystem: `mxcli log`

Everything logs at `INFO` by default, so the detail you need usually is not in the
file at all — and raising the whole runtime to `TRACE` is unusable. Logging is
publish/subscribe: code publishes to a named **LogNode**, and each node has its
own level.

```bash
mxcli log list # every node and its level (57 on a blank 11.12 app)
mxcli log list --filter connectionbus # narrow it — nobody remembers the exact names
mxcli log set ConnectionBus_Queries TRACE
mxcli log set ConnectionBus_Queries=TRACE Connector=DEBUG # one admin call
mxcli log set ConnectionBus_Queries INFO # put it back
```

Levels: `NONE CRITICAL ERROR WARNING INFO DEBUG TRACE`.

This needs a **running** app (it goes through the M2EE admin API), and the change
lasts as long as the process — it is a debugging knob, not project configuration.

Nodes worth knowing:

| Question | Node |
|---|---|
| What SQL is being run | `ConnectionBus_Queries` (and `_Retrieve`, `_Update`) |
| Database sync at startup | `ConnectionBus_Synchronize` |
| Consumed OData / REST calls | `ODataConsume`, `REST Consume` |
| **Published** OData requests (the incoming URI) | `OData Publish` — note the space; only exists if the project publishes a service |
| Microflow execution | `MicroflowEngine`, `ActionManager` |
| Scheduled events / queues | `SystemTask`, `TaskQueue` |
| Java/JS action wiring | `Connector` |

**`--force` creates the node, permanently.** Without it an unknown node is refused,
which is what you want — a typo should be an error. With it the name is registered
for the life of the process, so a typo becomes a real (empty) node that shows up in
`log list` from then on. Use it only to pre-register a node that has not published
yet.

**Nodes appear only once something registers them**, so the list is a property of
*this* app, not of Mendix. A blank 11.12 app reports 57; adding one published
OData service makes it 58. This is why `log list` is the first step rather than a
remembered name — and why `--force` exists for a node that has not registered yet.

### Seeing what a published OData resource is asked

`OData Publish` — **note the space** — is the node, and it exists only when the
project publishes a service. At TRACE it logs the full incoming URI, which is the
question `$filter`/`$top`/key-lookup bugs turn on:

```bash
mxcli log set "OData Publish" TRACE
# GET /odata/f1/Rows?$top=5&$filter=rowKey eq 'abc'
```
```
TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc'
DEBUG - OData Publish: Responding to client with status code 400.
```

`ODataConsume` is the *client* side — a different node for a different direction.

That same probe showed Mendix rejecting `$filter` on a property not declared
`Filterable`, with a **400 "Property 'rowKey' is non-filterable"**, before the read
microflow ran. So the platform does enforce the filterability you declare in
`expose (…)`; what it does *not* do is apply `$top`/`$skip`/`$orderby` for a
read-microflow resource (see `odata-data-sharing.md`).

## 2. Metrics — throughput and database pressure

`--metrics` registers a Prometheus registry, served at
Expand Down
128 changes: 128 additions & 0 deletions .claude/skills/mendix/odata-data-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,134 @@ Two things worth knowing before you write this:
`PublishAssociations` must stay at its default (Yes) here: a non-persistable
entity cannot publish its ID, so object-id mode can never build for it.

## HTTP Status Codes and Errors: What Each Capability Can Do

**The read path and the write path have different powers, and the difference is
the single most expensive thing to get wrong here.** Read this before designing
any microflow-backed resource.

| Capability | Can set the HTTP status code? | How |
|---|---|---|
| OData **action** (published microflow) | **Yes** | add a `System.HttpResponse` parameter |
| Entity **Insertable / Updatable / Deletable** microflow | **Yes** | add a `System.HttpResponse` parameter |
| Entity **Readable** microflow | **No** | not offered — the read capability has no documented `HttpResponse` parameter |

Sources: [published-odata-microflow §4](https://docs.mendix.com/refguide/published-odata-microflow/#4-customizing-the-outgoing-http-response),
[published-odata-entity, custom HTTP response](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response).
The custom-response section names Insertable, Updatable and Deletable; the
Readable section never references it.

### Writing a status code (action / insert / update / delete)

```sql
create microflow Api.InsertRow (
$Row: Api.Row,
$HttpResponse: System.HttpResponse
)
begin
if $Row/RowKey = empty then
change $HttpResponse (StatusCode = 400, Content = '{"error":"rowKey is required"}');
return;
end if;
...
end;
```

Three rules the platform imposes:

- **`ReasonPhrase` is ignored.** Setting it is dead code; put the explanation in
`Content`.
- **`204` always produces an empty body.** Setting `Content` alongside it is
discarded.
- **Changing status or content makes the whole response come from
`HttpResponse`** — headers included. Changing *only* headers merges them with
the defaults instead.
- `Transfer-Encoding` and `Date` cannot be changed.

### The read path cannot refuse, so it must not over-promise

A read microflow has no way to answer `400`. Its only exits are to throw (a
blunt `500`) or to return data. That has two consequences, and both are design
obligations rather than nice-to-haves:

**1. Declare capabilities you do not implement as `No`.** Mendix applies *no*
query options to a read-microflow resource — it hands over the request and
returns whatever comes back — so `TopSupported` / `SkipSupported` / `Countable`
are claims about your microflow, not about the platform. A resource that
advertises `TopSupported: Yes` and ignores `$top` returns the entire collection
with a `200`, and the client believes it received a page.

```sql
publish entity Api.Row as 'Rows' (
ReadMode: microflow Api.Read_Rows,
-- Only claim what Read_Rows actually parses out of the URI:
TopSupported: No,
SkipSupported: No,
Countable: No
)
```

Declaring `No` is the read path's substitute for the `400` it cannot send.

**2. Answer a lookup by your own KEY.** A client holding a row re-reads it by
key, unprompted, and Mendix's own OData client sends the `$filter` spelling:

```
?$filter=rowKey eq '1036-c' ← what the runtime actually sends
/Rows('1036-c') ← bare path key
/Rows(rowKey='1036-c') ← named path key
```

If the microflow parses only its collection filter, the key request falls through
to the collection default and the client adopts the **first row** as the identity
of the object it is displaying. There is no error: the request is well-formed,
the response is a valid collection, the count is right, the status is `200`. Two
different objects are then on screen at once, and nothing distinguishes them
until one travels to another page.

So: `expose ( … (KEY) )` is a promise the *service* makes on the *microflow's*
behalf. Branch key → id → filter → default.

**Not declaring the KEY is not a way out.** Mendix requires a published entity to
have one — `CE6585 "Published entity 'X' must have a key defined."` — so the only
correct resolution is to answer the lookup. (Query *options* you may decline;
the key you may not.)

The request itself always arrives on `System.HttpRequest`:

```sql
create microflow Api.Read_Rows (
$Request: System.HttpRequest,
$Response: System.ODataResponse -- required while Countable is Yes
)
returns List of Api.Row
begin
log info 'URI=' + $Request/Uri; -- the whole query string, URL-encoded
...
end;
```

To watch what clients actually send, raise the **`OData Publish`** log node (note
the space; it exists only when the project publishes a service):

```bash
mxcli log set "OData Publish" TRACE
```
```
TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc'
DEBUG - OData Publish: Responding to client with status code 400.
```

That is the fastest way to see a client re-reading a row by key, and it needs no
change to the model. `ODataConsume` is the client side, a different node.

Mendix validates field names before the microflow runs (`$filter=secretColumn eq 'x'`
is a `400` from the platform), and it enforces `Filterable`: filtering on a property
you did not declare filterable is rejected with `400 "Property 'x' is
non-filterable"` before the microflow runs. So the microflow only ever sees names
that exist in the published metadata. That is defence in depth, not a substitute for a
whitelist — it constrains the *name*, not what you do with it.

## Step-by-Step: Read-Write API with Microflow Handlers

For write operations (insert, update, delete), the OData service delegates to microflows that map between the view entity and the underlying persistent entities.
Expand Down
37 changes: 37 additions & 0 deletions .claude/skills/mendix/run-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,43 @@ Playwright + the devcontainer's Chromium).
skips the bundle and just hot-reloads. It uses `CHOKIDAR_USEPOLLING` because inotify
is silent on container filesystems.
- Without `--watch`, a single one-shot bundle (~7 s) runs before boot.
- **The bundle is re-checked after the boot**, because bundling before it is not
enough: the runtime's boot runs Gradle `clean-custom-classes compile package`,
and when Gradle has work to do (a new Java action, a full recompile) its package
pass repopulates `deployment/web` and deletes `dist/` — the bundle written
seconds earlier by the same command. If that happened, `run --local` says
`re-bundling` and rebuilds it. When Gradle had nothing to do the check is a
`stat` and costs nothing.

**If you ever see a black page:** that is this failure, and nothing else reports
it — `mxcli check` passes, the build succeeds, the runtime log is quiet, `curl /`
returns **200** with a valid HTML shell, and the OData services all answer. Only a
browser sees it. Confirm with `curl -o /dev/null -w '%{http_code}' <app>/dist/index.js`;
a 404 there is the whole diagnosis.

`mxcli test --local` boots the same way and destroys the bundle too. Tests are
headless so it is not rebuilt for them (that would cost ~30 s on a loop whose point
is two seconds) — the run prints a note instead, and a subsequent `run --local`
restores it.

### Screenshots when the app has an https root URL (`--hub`)

Under `--hub` the runtime boots with the public **https** root URL, so it marks
its session cookies `Secure` and prefixes them `__Host-`. The screenshot login
therefore declares the real scheme with `X-Forwarded-Proto: http`, which on
Mendix 10.24+ takes precedence over `ApplicationRootUrl` and drops both — so the
captured session is usable over http.

Measured on 11.12.1, `__Host-XASSESSIONID (secure)` becomes `XASSESSIONID`
(not secure). Real users still arrive over https through the hub without that
header and still get `Secure` cookies.

One correction to a common assumption: this is **not** needed for `127.0.0.1`.
Loopback is a *trustworthy origin*, so Chromium accepts `Secure` cookies there
and an app with an https root URL renders and logs in fine over
`http://127.0.0.1:8080`. It matters when the browser reaches the app from a
non-loopback host — a container name, a LAN address — where the origin is not
trustworthy and the session cannot be held at all.

## Pixel-perfect page loop

Expand Down
6 changes: 6 additions & 0 deletions cmd/mxcli/cmd_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ Examples:
// the model quietly lacked what the author asked for.
violations = append(violations, executor.ValidateODataProperties(prog)...)

// Flag a microflow-backed OData resource whose read microflow cannot keep
// the promises the service makes for it. A read microflow has no
// System.HttpResponse parameter, so it cannot answer 400 — its contract
// has to be declared correctly up front, and nothing else checks that.
violations = append(violations, executor.ValidateODataReadContract(prog)...)

// Flag a page whose widgets point at a page created further down the same
// script. `exec` resolves page references in statement order and is not
// transactional, so this fails after earlier statements are already
Expand Down
Loading
Loading