diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json new file mode 100644 index 0000000000..aac9d47c3c --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-09", "symptom": "Every mxcli project's docker stack was the Compose project `docker`: containers docker-db-1 / docker-mendix-1 and volume docker_postgres-data were shared, so a second app's `docker up` replaced the first app's containers and two apps used one Postgres volume", "cause": "docker init wrote a compose file with no top-level `name:`, and mxcli runs compose with cmd.Dir = /.docker, so Compose derived the project name from the folder, which is `.docker` for every app (normalised to `docker`).", "file": "cmd/mxcli/docker/composename.go + init.go + templates/docker-compose.yml", "fix": "Template v3 has a top-level `name:` filled in by docker init (mxcli-). A stack's name is the key to its data, so it is chosen only when the compose file is created from nothing: --force on an existing file keeps its name: line, and on an old file with none pins the name Compose was using (COMPOSE_PROJECT_NAME from the old .env if set, else the normalised folder name, `docker`) and prints how to opt in.", "insight": "Anything that changes a Compose project name strands the old named volumes silently (new empty volumes appear, nothing errors), so a fix to a naming default must be new-files-only and the regeneration path must read the old file before overwriting it. Also check what the regeneration overwrites besides the file: --force rewrites .env, which is where the pre-fix workaround (COMPOSE_PROJECT_NAME) lived. The name goes in the stamped compose file, not .env, so a .env that is recreated by itself can never rename a stack. Verified read-only with `docker compose config` in temp dirs; no code path hardcodes container or volume names (all compose calls address services, not containers).", "refs": ["mendixlabs/mxcli#1364"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-describe-snippet-parameter-dataview-source.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-describe-snippet-parameter-dataview-source.json new file mode 100644 index 0000000000..bcaa4f937a --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-describe-snippet-parameter-dataview-source.json @@ -0,0 +1 @@ +{"date": "2026-10-12", "area": "mdl/executor", "symptom": "`describe snippet` printed a data view sourced from a snippet parameter (`dataview dv (DataSource: $Parent)` inside `Params: ($Parent: …)`) with no DataSource; re-executing the output stored SourceVariable: null and mx check failed CE0488 \"No entity configured for the data source of this data view\".", "cause": "parseContextSource (cmd_pages_describe_datasource.go) read only SourceVariable.PageParameter. Inside a snippet the name lives in the SnippetParameter slot with PageParameter empty. The association-source reader beside it (associationSourcePath, #721 L3) and the pluggable reader already fell back to SnippetParameter; this was the one copy that did not.", "fix": "parseContextSource falls back to SourceVariable.SnippetParameter and returns the same `parameter` source.", "insight": "A slot-pair bug fixed in one reader is a list of every reader of that BSON field: `grep -rn '\\[\"PageParameter\"\\]' mdl/` finds four, and one had been missed. The decisive check is a round trip, not the describe text: exec the describe output on a copy and mx check it. Here the old output gave CE0488 and the fixed output gave 'Unchanged snippet' (write elided) and 0 errors. Found by eye while dumping BSON for an unrelated issue (#1379): the stored data view had SnippetParameter set but the describe text showed no source.", "issue": "ako/mxcli#1138", "file": "mdl/executor/cmd_pages_describe_datasource.go (parseContextSource)", "test": "mdl/executor/cmd_pages_describe_datasource_test.go TestParseDataSourceCoversEveryStoredType/snippet_parameter, mdl-examples/bug-tests/1138-describe-snippet-parameter-dataview-source.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json new file mode 100644 index 0000000000..41b67178bf --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json @@ -0,0 +1 @@ +{"date": "2026-10-12", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1252: a Studio Pro 11.12.3 Aggregate list activity with a custom error handler (with or without rollback) described as a bare `$count = count($TEYAs);`, and `on error` was a parse error (mismatched input 'on' expecting ';') on list operations, aggregates, create list, add/remove, cast, rollback and show home page. MDL077 additionally refused the clause on list operations and aggregates.", "cause": "THREE LAYERS, one false premise. (1) Grammar: eight statement rules had no onErrorClause. (2) MDL077 asserted Microflows$ListOperationsAction and AggregateAction have no ErrorHandlingType 'in the metamodel' — the Model SDK declares it on the base Microflows$MicroflowAction, which every one of them extends, with an unbounded StructureVersionInfo; the vendored gen types and the #591 writers carried it too. (3) Reader: actionFromGen never read ErrorHandlingType off RollbackAction or ShowHomePageAction (and their sdk structs had no field), so DESCRIBE's gate saw \"\" and dropped the whole handler branch. The list activities DID read it, so on HEAD their describe emitted a live clause the grammar then refused — the #1078 trap again.", "file": "mdl/grammar/domains/MDLMicroflow.g4; mdl/visitor/visitor_microflow_statements.go; mdl/visitor/visitor_microflow_actions.go; mdl/executor/cmd_microflows_builder_actions.go; mdl/executor/validate_microflow_error_handling.go; mdl/executor/nanoflow_validation.go; mdl/backend/modelsdk/microflow_read_actions.go; mdl/backend/modelsdk/microflow_write.go; sdk/microflows/microflows_actions.go", "fix": "onErrorClause on the eight rules; AST fields; builders use ehType(clause) + finishCustomErrorHandler (rollback/home page use explicitErrorHandling so no-clause BSON is unchanged); add/remove to a reference set pass the clause to the Change object activity; MDL077 (error-handling) removed; MDL076 and the nanoflow without-rollback-only rule extended from a measured matrix; reader/writer carry the type on rollback and show home page.", "test": "TestAuthorOnError_1252_*, TestMDL076_1252_ContinueRefusedWhereMxbuildRejectsIt, TestNanoflow_1252_ListActivitiesTakeOnlyWithoutRollback, TestDescribe_1252_ErrorHandlerSurvives, TestActionFromGen_1252_ReadsErrorHandlingType, TestMicroflowRoundTrip_1252_*; mdl-examples/bug-tests/1252-on-error-list-activities.mdl", "insight": "**Settle 'not in the metamodel' against the Model SDK before writing a rule on it.** MDL077's premise took one grep to refute: `npm pack mendixmodelsdk`, then errorHandlingType sits on the base MicroflowAction class every action extends — a property on a base class is invisible to a per-type search. A refusal rule built on an unverified absence turns a capability gap into an enforced one. **Measure, do not extrapolate, the accepted forms**: one 11.12.3 project with one microflow AND one nanoflow per (statement x clause) cell — 85 documents, a single `mx check -j out.json` (the JSON names the document; the text output does not) — gave the whole matrix in one build. It is not guessable: in a microflow all eight take custom handlers and reject continue EXCEPT rollback, which takes continue; in a nanoflow the list activities and cast share create/commit's 'without rollback only' row while rollback takes everything; show home page is microflow-only. **Default-preservation is per writer**: rollback's writer never stored a type and show home page's always stored a literal Rollback, so both use explicitErrorHandling; the list activities already wrote ehType(nil) and keep ehType. Controls: stub the visitor assignment -> builder test reports 0 Custom activities; drop the reader line -> ErrorHandlingType \"\"; drop the MDL076 rows -> continue accepted. describe -> exec of the matrix reports every document already in sync."} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json new file mode 100644 index 0000000000..be03567bbc --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json @@ -0,0 +1 @@ +{"date": "2026-10-12", "area": "mdl/grammar", "symptom": "`column (Attribute: \"MyModule\".\"AssocA_2\"/\"MyModule\".\"AssocB_2\"/\"NId\")` in a Data grid 2 over a junction entity failed `mxcli check` with MDL-WIDGET34 \"Attribute: &{…} is not an attribute binding MDL can store\" plus MDL-WIDGET42 \"cannot hold the expression\" (mxcli 0.25.0, Mendix 11.12.4).", "cause": "Same root as the 2026-10-11 qualified-association-hop finding: attributePathV3 took only bare identifiers between slashes, so module-qualified hops fell through to `keyword COLON expression` and parsed as a division. Already fixed by 05a05aad, which shipped after the 0.25.0 the reporter ran; its tests pinned two qualified hops at the parser but only one hop at the builder.", "fix": "No code change. Added the reported shape as tests at the check layer (the issue's script verbatim through visitor.Build + validateWidgetTree) and at the builder (two qualified hops -> Attribute MyModule.AssocB.NId with two IndirectEntityRef steps, equal to the bare spelling), plus a bug-test MDL. mx check on 11.12.4: 0 errors with and without widget normalisation.", "insight": "Before fixing a report, check whether HEAD already passes the reporter's script verbatim and build the commit before the suspected fix as a control: here the pre-fix binary reproduced the exact `&{…}` WIDGET34 text, which proves the match rather than assuming it from the error code. A 'two hops' parser case does not cover the builder walking two hops — pin each layer with the reported depth. Wrong turn to skip: the reporter's entities are external (OData); the path resolution is the same for persistent entities, so the repro does not need an OData service. Side observation: DESCRIBE of a snippet data view sourced from a snippet parameter prints no DataSource although the BSON stores SnippetParameter.", "issue": "mendixlabs/mxcli#1379", "file": "mdl/grammar/domains/MDLPage.g4 (attributePathV3), mdl/executor/cmd_pages_builder_input.go (resolveAssociationPathIn)", "test": "mdl/executor/cmd_pages_builder_multihop_column_test.go, mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json new file mode 100644 index 0000000000..cd9be6b016 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-12","symptom":"`show message … type error` stored Microflows$ShowMessageAction Type \"error\"; the runtime showed an Information dialog for an error message while `mx check` reported 0 errors (mendixlabs/mxcli#1280). `type ok` was stored as \"ok\" the same way","cause":"buildShowMessageStatement copied the `identifierOrKeyword` text verbatim; the stored enumeration value is case-sensitive and nothing between the visitor and the writer validated it","file":"`mdl/visitor/visitor_microflow_actions.go` (buildShowMessageStatement), `mdl/executor/cmd_microflows_builder_calls.go` (addShowMessageAction), `sdk/microflows/microflows_actions.go` (CanonicalMessageType), `mdl/executor/validate_microflow_show_message.go` (MDL-SHOWMSG01)","insight":"**An enum value taken from a free-form `identifierOrKeyword` needs case-folding to the gen enum's spelling AND a check for non-members** — skills write keywords in lower case, so the lower-case spelling is the common one, and `mx check` accepts any string for an enumeration property, so neither mx check nor a build catches it; only the stored bytes or the running app do. Normalise in the visitor (so declared-vs-described AST comparison agrees) and again in the writer. Verified on 11.12.4: the pre-fix binary stores `Type = error`, the fixed one `Error`, and `docker check` passes both. Grep other `identifierOrKeyword` enum slots for the same shape","refs":["mendixlabs/mxcli#1280"],"ce":[]} diff --git a/.claude/skills/mendix/docker-workflow/SKILL.md b/.claude/skills/mendix/docker-workflow/SKILL.md index 2611d4e36c..82b1b0e839 100644 --- a/.claude/skills/mendix/docker-workflow/SKILL.md +++ b/.claude/skills/mendix/docker-workflow/SKILL.md @@ -15,11 +15,11 @@ description: "Build, run and validate a Mendix app in Docker from inside a devco | 53-68 | Architecture | | 69-85 | Quick Start: One Command | | 86-113 | Creating an Empty Mendix App | -| 114-272 | Step-by-Step Workflow | -| 273-303 | Common Workflow: Edit, Rebuild, Test | -| 304-415 | Hot Reload | -| 416-434 | Environment Variables | -| 435-454 | Troubleshooting | +| 114-274 | Step-by-Step Workflow | +| 275-305 | Common Workflow: Edit, Rebuild, Test | +| 306-417 | Hot Reload | +| 418-436 | Environment Variables | +| 437-457 | Troubleshooting | # Docker Build & Run Skill @@ -167,6 +167,8 @@ mxcli docker init -p app.mpr This creates a `.docker/` directory with Docker Compose configuration for the Mendix app + PostgreSQL. +**Stack name:** `docker init` writes a Compose project name into `.docker/docker-compose.yml` (`name: "mxcli-"`), so containers and volumes of different apps are separate (`mxcli-myapp-db-1`, `mxcli-myapp_postgres-data`). Never change `name:` on a stack that holds data you need: Compose then starts new containers with an empty database and leaves the old volume behind. Projects initialised by older mxcli have no `name:`, so Compose calls them `docker` (`docker-db-1`, `docker_postgres-data`), shared with every other such project; `docker init --force` keeps that name rather than stranding the data. + **Port conflicts:** If default ports (8080/8090/5432) are already in use, check with `ss -tlnp | grep -E '808|809|543'` and use `--port-offset N` to shift all ports: ```bash @@ -450,5 +452,6 @@ All defaults can be overridden in `.docker/.env`. | `password should not be empty (debugger)` | Add `RUNTIME_DEBUGGER_PASSWORD` — re-run `mxcli docker init --force` | | `security level should be set to CHECKEVERYTHING` | App in Production mode without security — set to Development mode or configure security | | Port 8080 not accessible | Check `forwardPorts` in devcontainer.json includes 8080 | +| Two projects overwrite each other's containers or share one database (both show as project `docker`) | Old compose file without `name:`. Back up the data, set a unique `name:` at the top of `.docker/docker-compose.yml` (e.g. `mxcli-myapp`), then `docker compose up`; the new stack starts with an empty database | | Database errors on startup | Try `mxcli docker up -p app.mpr --fresh` to reset volumes | | OQL: "Action not found: preview_execute_oql" | Runtime needs `-Dmendix.live-preview=enabled` JVM flag — re-run `mxcli docker init --force` to get the updated docker-compose.yml | diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index b1807a22b2..3279491a77 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -712,8 +712,8 @@ close page on error begin return; end error; of them; `continue` is fine on `declare`, `set`, `retrieve`, `delete` and `call microflow`. Measured on 11.14.0 — note that create-*variable* and change-*variable* accept `continue` while change-*object* does not. -- **List operations and aggregates** (`$x = head $l;`, `$n = count $l;`) - have no error handling in Mendix at all — **MDL077**. +- **List/aggregate activities, `create list`, `add`/`remove`, `cast`, `show home + page`**: no `continue` (MDL076, 11.12.3, #1252); `rollback` takes every form. **In a nanoflow, almost none of them take a clause at all.** `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 diff --git a/.claude/skills/mendix/write-nanoflows/SKILL.md b/.claude/skills/mendix/write-nanoflows/SKILL.md index d6996c37a6..3aadc22379 100644 --- a/.claude/skills/mendix/write-nanoflows/SKILL.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -21,11 +21,11 @@ description: "Nanoflow syntax in MDL — shared with microflows, but client-side | 482-552 | NanoflowCommons JavaScript Actions Reference | | 553-591 | Disallowed Activities | | 592-595 | Return Type Restrictions | -| 596-639 | Error Handling in Nanoflows | -| 640-647 | Security (GRANT/REVOKE) | -| 648-660 | Management Commands | -| 661-671 | Common Mistakes | -| 672-686 | Validation Checklist | +| 596-640 | Error Handling in Nanoflows | +| 641-648 | Security (GRANT/REVOKE) | +| 649-661 | Management Commands | +| 662-672 | Common Mistakes | +| 673-687 | Validation Checklist | # Mendix Nanoflow Skill @@ -620,13 +620,14 @@ END ERROR; ### Which activities take which clause in a nanoflow -Measured on Mendix 11.14.0; every other form is **CE6035** "Error handling type -is not supported", and mxcli refuses it (MDL091): +Measured on Mendix 11.14.0 (the list activities, cast and rollback on 11.12.3, +mendixlabs/mxcli#1252); every other form is **CE6035** "Error handling type is +not supported", and mxcli refuses it (MDL091): | Activity in a nanoflow | Accepted clauses | |---|---| -| `DECLARE`, `SET`, `RETRIEVE`, `DELETE` | every form | -| `CREATE`, `COMMIT`, `CALL NANOFLOW`, `CALL MICROFLOW` | only `ON ERROR WITHOUT ROLLBACK BEGIN … END ERROR` | +| `DECLARE`, `SET`, `RETRIEVE`, `DELETE`, `ROLLBACK` | every form | +| `CREATE`, `COMMIT`, `CALL NANOFLOW`, `CALL MICROFLOW`, list operations (`head`, `filter`, …), aggregates (`count`, `sum`, …), `CREATE LIST OF`, `ADD … TO $List`, `REMOVE … FROM $List`, `CAST` | only `ON ERROR WITHOUT ROLLBACK BEGIN … END ERROR` | | `CHANGE`, `LOG`, `SHOW PAGE`, `CLOSE PAGE`, `SHOW MESSAGE`, `VALIDATION FEEDBACK` | none | mxcli refuses the clause rather than writing a nanoflow mxbuild rejects. The diff --git a/CHANGELOG.md b/CHANGELOG.md index 941ae1731b..ae13d611de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Headline: **Documents mxcli writes now survive Studio Pro's merge and diff: ever ### Fixed - **`check -p` on `create or modify` of stored microflows is back to v0.24.0's speed** (mendixlabs/mxcli#1272) — the passes that predict exec's verdict and resolve references looked each flow up by name several times per statement, and every lookup re-read and fully decoded every microflow file in the project, so the cost per statement grew with the project. Each check pass now reads a unit file once, and a lookup by name or ID decodes only the flows it returns. On a blank 11.14 app with 200 microflows, re-declaring 25 / 100 / 200 of them: 3.5 / 11.7 / 23.7 s → 0.8 / 1.8 / 3.0 s (v0.24.0: 0.9 / 2.6 / 3.7 s). `exec` gains the same through its preflight and its own planning of each statement; an unchanged re-run of the 200 went from 40 s to 5 s. Nothing reported changes. +- **Every project's docker stack is its own Compose project instead of all sharing the name `docker`** (mendixlabs/mxcli#1364) — mxcli runs `docker compose` in the project's `.docker/` folder and Compose names a stack after that folder, so every app was the project `docker`: containers `docker-db-1` / `docker-mendix-1` and volume `docker_postgres-data` were shared, a second app's `docker up` replaced the first app's containers, and two apps pointed at one Postgres volume. `mxcli docker init` now writes a top-level `name: "mxcli-"` into `docker-compose.yml` (template v3; `Customer Portal/app.mpr` becomes `mxcli-customer-portal`; `docker compose -p` and `COMPOSE_PROJECT_NAME` still override it), so the containers are `mxcli-customer-portal-db-1` and the volume `mxcli-customer-portal_postgres-data`. **Existing projects keep their stack and their data:** a compose file written by an older mxcli has no `name:`, nothing is rewritten without `--force`, and `docker init --force` on such a project writes `name: "docker"` (or the `COMPOSE_PROJECT_NAME` from its old `.env`, which `--force` rewrites and which overrides `name:` in Compose, carried into `name:`) — the name Compose was already using — and says so; a `name:` you set yourself is kept as well. Mxcli never renames a stack on its own, because a new name makes Compose start new containers with an **empty** database and leave the old volume behind. **To opt in** on a project that has `name: "docker"`, stop the stack (`docker compose down` in `.docker/`, without `-v`), change `name:` to a unique value such as `mxcli-myapp`, then `mxcli docker up`; the database starts empty, and the old data stays in `docker_postgres-data` (to carry it over, dump it with `pg_dump` before you change the name and restore it into the new stack). To check which name a stack uses: `docker compose config` in `.docker/` prints `name:`. Two app folders with the same name still collide — edit `name:` in one of them. - **A navigation sub-menu no longer keeps the action of the page item it replaces** (mendixlabs/mxcli#1341) — turning menu item `'Work'` into sub-menu `'Work'` with `create or replace navigation` (or `create or modify menu`) paired the two by caption and carried the page item's `Forms$FormAction` onto the sub-menu, reporting it as an action "MDL cannot express"; `mx check` then failed with CE0548 "Items with subitems cannot have an action themselves." A sub-menu is now always written with no action (measured on 11.14.0). - **`mxcli test --attach` and the dev loop's "already serving" detection see a live app on Windows** (mendixlabs/mxcli#1284) — both read a pid from a handshake file (`.mxcli/test-endpoint.json`, `.mxcli/run-local.json`) and tested it with `Signal(0)`, which Go supports only as Kill on Windows, so every pid read as dead: `test --attach` always refused with "the app that published … is no longer running" while the app was up, and a command that looks for a dev loop already serving the project (the recompile warning, for one) never found it. Liveness is now `internal/procalive.Alive`, which uses OpenProcess and WaitForSingleObject on Windows (the check `run --local` already used for mxbuild) and `Signal(0)` elsewhere. No change on Linux or macOS. - **`run stop` on Windows no longer reports a run that has shut down as still alive** — it printed "failed: 1 process(es) of pid N's run are still alive" and exited 1 for a run that had stopped, because its liveness check counted any pid Windows could still open as alive, and an exited process stays openable while its parent holds a handle to it. It now uses the same check as `test --attach` (above), and reports "stopped: pid N". diff --git a/cmd/mxcli/docker.go b/cmd/mxcli/docker.go index 62c859052a..549ffb17ad 100644 --- a/cmd/mxcli/docker.go +++ b/cmd/mxcli/docker.go @@ -239,6 +239,14 @@ Creates a .docker/ directory next to the MPR file containing: The compose file references ./build as the build context, which is where 'mxcli docker build' outputs the PAD package. +Each project gets its own Compose project name ("name:" at the top of +docker-compose.yml, "mxcli-"), so the containers and database volume +of different apps no longer collide. The name identifies the stack's data: +changing it makes Compose start new containers with an empty database. --force +therefore keeps the name an existing docker-compose.yml already has; a file +written by an older mxcli (no name, so Compose used "docker") stays "docker" +until you edit 'name:' yourself. + Examples: mxcli docker init -p app.mpr mxcli docker init -p app.mpr --force diff --git a/cmd/mxcli/docker/composename.go b/cmd/mxcli/docker/composename.go new file mode 100644 index 0000000000..bfe5e803b2 --- /dev/null +++ b/cmd/mxcli/docker/composename.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Compose project names (mendixlabs/mxcli#1364). +// +// mxcli runs `docker compose` with the project's .docker/ folder as the working +// directory, and Compose names a stack after that folder. Every project's folder +// is called ".docker", so every project was the Compose project "docker" and +// shared container names (docker-db-1) and volumes (docker_postgres-data): two +// apps' stacks replaced each other and their databases collided. +// +// New compose files therefore carry a top-level `name:` derived from the app. +// The name lives only in docker-compose.yml (not in .env) because that file is +// the one stamped with the template version and the one `docker init --force` +// regenerates deliberately; a .env that gets recreated by itself must never be +// able to rename a stack. Precedence in Compose is `-p` > COMPOSE_PROJECT_NAME +// (shell or .env) > `name:` > folder name, so users can still override it. +// +// The name is part of a stack's identity: a different name makes Compose create +// new, EMPTY volumes and leaves the old database volume behind. So a name is only +// ever chosen when a compose file is created from nothing. Regenerating an +// existing file (--force) carries over whatever name the stack already had. + +const ( + // composeNamePlaceholder is the token in templates/docker-compose.yml that + // is replaced by the project name when the file is written. + composeNamePlaceholder = "__COMPOSE_PROJECT_NAME__" + + // composeNamePrefix marks stacks created by mxcli so they are recognisable + // in `docker ps` / `docker volume ls`. + composeNamePrefix = "mxcli-" + + // legacyComposeProjectName is what Compose called every stack before the + // template had a name: the normalised ".docker" folder name. + legacyComposeProjectName = "docker" + + // maxComposeNameBase bounds the app-derived part so container, network and + // volume names stay well inside the 63-character DNS label limit. + maxComposeNameBase = 48 +) + +var ( + invalidComposeNameChars = regexp.MustCompile(`[^a-z0-9_-]+`) + repeatedDashes = regexp.MustCompile(`-{2,}`) + // topLevelNameRegex matches an unindented `name:` line of a compose file and + // captures its value up to whitespace or a trailing comment. + topLevelNameRegex = regexp.MustCompile(`(?m)^name:[ \t]*([^\s#]+)`) + envProjectNameRe = regexp.MustCompile(`(?m)^COMPOSE_PROJECT_NAME=[ \t]*([^\r\n#]*)`) +) + +// sanitizeComposeName reduces s to what a Compose project name may contain +// (lowercase letters, digits, '-' and '_', starting with a letter or digit). +// Anything else becomes a dash; runs of dashes collapse; at most +// maxComposeNameBase characters are kept. Returns "" when nothing usable is left. +func sanitizeComposeName(s string) string { + s = invalidComposeNameChars.ReplaceAllString(strings.ToLower(s), "-") + s = repeatedDashes.ReplaceAllString(s, "-") + s = strings.Trim(s, "-_") + if len(s) > maxComposeNameBase { + s = strings.Trim(s[:maxComposeNameBase], "-_") + } + return s +} + +// ComposeProjectNameFor returns the Compose project name for a new stack of the +// app whose .mpr is projectPath: "mxcli-" plus the app folder's name, so +// ~/work/Customer Portal/app.mpr becomes "mxcli-customer-portal". The folder is +// preferred over the .mpr name because projects commonly call the file "app.mpr". +// If the folder gives nothing usable the .mpr base name is used, then "app". +func ComposeProjectNameFor(projectPath string) string { + abs, err := filepath.Abs(projectPath) + if err != nil { + abs = projectPath + } + base := sanitizeComposeName(filepath.Base(filepath.Dir(abs))) + if base == "" { + file := filepath.Base(abs) + base = sanitizeComposeName(strings.TrimSuffix(file, filepath.Ext(file))) + } + if base == "" { + base = "app" + } + return composeNamePrefix + base +} + +// normalizeLikeCompose applies Docker Compose's own rule for deriving a project +// name from a directory name: lowercase, drop every character outside +// [a-z0-9_-], then trim leading '_' and '-'. ".docker" -> "docker". +func normalizeLikeCompose(s string) string { + s = invalidComposeNameChars.ReplaceAllString(strings.ToLower(s), "") + return strings.TrimLeft(s, "_-") +} + +// existingComposeName returns the raw value of the top-level `name:` of a +// compose file (quotes and interpolation untouched) and whether there was one. +func existingComposeName(data []byte) (string, bool) { + m := topLevelNameRegex.FindSubmatch(data) + if m == nil { + return "", false + } + return string(m[1]), true +} + +// envComposeProjectName returns COMPOSE_PROJECT_NAME from dockerDir/.env, as +// Compose normalises it, if the user set it (the workaround for #1364). +func envComposeProjectName(dockerDir string) (string, bool) { + env, err := os.ReadFile(filepath.Join(dockerDir, ".env")) + if err != nil { + return "", false + } + m := envProjectNameRe.FindSubmatch(env) + if m == nil { + return "", false + } + v := normalizeLikeCompose(strings.Trim(strings.TrimSpace(string(m[1])), `"'`)) + return v, v != "" +} + +// legacyComposeName returns the project name that a compose file with no +// `name:` in dockerDir has been getting: the folder name as Compose normalises +// it (".docker" -> "docker"). +func legacyComposeName(dockerDir string) string { + abs, err := filepath.Abs(dockerDir) + if err != nil { + abs = dockerDir + } + if n := normalizeLikeCompose(filepath.Base(abs)); n != "" { + return n + } + return legacyComposeProjectName +} + +// composeNameDecision is the name written into a (re)generated compose file and +// where it came from. +type composeNameDecision struct { + // Value is the YAML scalar to write after `name:` (already quoted when generated). + Value string + // Name is the bare project name, for messages. + Name string + // Source is "env" (COMPOSE_PROJECT_NAME in the old .env), "kept" (an existing + // name: line), "legacy" (an existing file with no name, pinned to the name + // Compose was already using) or "new". + Source string +} + +// decideComposeName chooses the project name for the compose file about to be +// written at composePath. See the package comment above for why an existing +// file's name always wins over a freshly derived one. +func decideComposeName(projectPath, dockerDir, composePath string) composeNameDecision { + // COMPOSE_PROJECT_NAME in .env beats `name:` in Compose's precedence, so it + // is the stack's real name wherever it is set. `--force` rewrites .env + // without it, so it must be carried into the compose file now or the stack + // is renamed (and its volumes stranded) by the rewrite. + if v, ok := envComposeProjectName(dockerDir); ok { + return composeNameDecision{Value: fmt.Sprintf("%q", v), Name: v, Source: "env"} + } + if data, err := os.ReadFile(composePath); err == nil { + if raw, ok := existingComposeName(data); ok { + return composeNameDecision{Value: raw, Name: strings.Trim(raw, `"'`), Source: "kept"} + } + legacy := legacyComposeName(dockerDir) + return composeNameDecision{Value: fmt.Sprintf("%q", legacy), Name: legacy, Source: "legacy"} + } + name := ComposeProjectNameFor(projectPath) + return composeNameDecision{Value: fmt.Sprintf("%q", name), Name: name, Source: "new"} +} + +// renderComposeTemplate fills the project name into the compose template. +func renderComposeTemplate(data []byte, d composeNameDecision) ([]byte, error) { + if !strings.Contains(string(data), composeNamePlaceholder) { + return nil, fmt.Errorf("compose template has no %s placeholder", composeNamePlaceholder) + } + return []byte(strings.Replace(string(data), composeNamePlaceholder, d.Value, 1)), nil +} diff --git a/cmd/mxcli/docker/composename_test.go b/cmd/mxcli/docker/composename_test.go new file mode 100644 index 0000000000..36fb094fc3 --- /dev/null +++ b/cmd/mxcli/docker/composename_test.go @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSanitizeComposeName(t *testing.T) { + cases := []struct { + in, want string + }{ + {"MyApp", "myapp"}, + {"my app", "my-app"}, + {"My Cool__App!!", "my-cool__app"}, + {" --Leading and trailing-- ", "leading-and-trailing"}, + {"_underscore-first", "underscore-first"}, + {"app.v2", "app-v2"}, + {"Ünïcode-Ñame", "n-code-ame"}, + {"123", "123"}, + {"", ""}, + {"!!!", ""}, + {strings.Repeat("a", 80), strings.Repeat("a", maxComposeNameBase)}, + {strings.Repeat("a", maxComposeNameBase-1) + "-bbb", strings.Repeat("a", maxComposeNameBase-1)}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := sanitizeComposeName(c.in); got != c.want { + t.Errorf("sanitizeComposeName(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestComposeProjectNameFor(t *testing.T) { + root := t.TempDir() + cases := []struct { + name string + mpr string + want string + }{ + {"folder wins over mpr base", filepath.Join(root, "Customer Portal", "app.mpr"), "mxcli-customer-portal"}, + {"folder with punctuation", filepath.Join(root, "MyApp_v2.0", "MyApp.mpr"), "mxcli-myapp_v2-0"}, + {"mpr base when folder is unusable", filepath.Join(root, "!!!", "Billing.mpr"), "mxcli-billing"}, + {"fallback when nothing usable", filepath.Join(root, "!!!", "???.mpr"), "mxcli-app"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ComposeProjectNameFor(c.mpr); got != c.want { + t.Errorf("ComposeProjectNameFor(%q) = %q, want %q", c.mpr, got, c.want) + } + }) + } +} + +func TestNormalizeLikeCompose(t *testing.T) { + // Mirrors docker compose's own derivation of a project name from the + // working directory: lowercase, drop invalid characters, trim leading _/-. + cases := []struct{ in, want string }{ + {".docker", "docker"}, + {"docker", "docker"}, + {"My Docker.Dir", "mydockerdir"}, + {"_x-y_", "x-y_"}, + {"...", ""}, + } + for _, c := range cases { + if got := normalizeLikeCompose(c.in); got != c.want { + t.Errorf("normalizeLikeCompose(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestExistingComposeName(t *testing.T) { + cases := []struct { + name string + in string + want string + wantOK bool + }{ + {"none", "services:\n mendix:\n name: not-top-level\n", "", false}, + {"plain", "name: foo\nservices:\n", "foo", true}, + {"double quoted", "# c\nname: \"mxcli-x\"\nservices:\n", "\"mxcli-x\"", true}, + {"single quoted with comment", "name: 'a-b' # why\nservices:\n", "'a-b'", true}, + {"interpolated", "name: ${PROJ:-mine}\n", "${PROJ:-mine}", true}, + {"commented out", "# name: nope\nservices:\n", "", false}, + {"indented is not top level", " name: nope\n", "", false}, + {"crlf", "name: foo\r\nservices:\r\n", "foo", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := existingComposeName([]byte(c.in)) + if got != c.want || ok != c.wantOK { + t.Errorf("existingComposeName() = (%q, %v), want (%q, %v)", got, ok, c.want, c.wantOK) + } + }) + } +} + +// initInto runs `docker init` for a fresh app folder named folder under root +// and returns the generated compose file's content. +func initInto(t *testing.T, root, folder string) (compose string, out string) { + t.Helper() + appDir := filepath.Join(root, folder) + if err := os.MkdirAll(appDir, 0755); err != nil { + t.Fatal(err) + } + mpr := filepath.Join(appDir, "app.mpr") + if err := os.WriteFile(mpr, nil, 0644); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatalf("Init: %v", err) + } + data, err := os.ReadFile(filepath.Join(appDir, ".docker", "docker-compose.yml")) + if err != nil { + t.Fatal(err) + } + return string(data), buf.String() +} + +func TestInit_EachProjectGetsItsOwnComposeName(t *testing.T) { + // Issue #1364: compose derived the project name from the ".docker" folder, + // so every mxcli project was the compose project "docker" and their + // containers and volumes collided. + root := t.TempDir() + a, _ := initInto(t, root, "AppOne") + b, _ := initInto(t, root, "AppTwo") + + nameA, okA := existingComposeName([]byte(a)) + nameB, okB := existingComposeName([]byte(b)) + if !okA || !okB { + t.Fatalf("generated compose files must carry a top-level name: (%q, %v) (%q, %v)", nameA, okA, nameB, okB) + } + if nameA == nameB { + t.Errorf("two projects share compose project name %s", nameA) + } + if nameA != `"mxcli-appone"` || nameB != `"mxcli-apptwo"` { + t.Errorf("names = %s, %s; want \"mxcli-appone\", \"mxcli-apptwo\"", nameA, nameB) + } + for _, c := range []string{a, b} { + if strings.Contains(c, composeNamePlaceholder) { + t.Error("template placeholder left in generated compose file") + } + if got := composeVersionOf(c); got < 3 { + t.Errorf("generated template version = %d, want >= 3 (the name: line is part of v3)", got) + } + } +} + +func composeVersionOf(content string) int { return parseTemplateVersion([]byte(content)) } + +func TestInit_ReportsComposeProjectName(t *testing.T) { + _, out := initInto(t, t.TempDir(), "Shop") + if !strings.Contains(out, "mxcli-shop") { + t.Errorf("init output should name the compose project; got:\n%s", out) + } +} + +// legacyProject lays out a project initialised by an older mxcli: a compose +// file with no name, so Compose named the stack after the ".docker" folder. +func legacyProject(t *testing.T, composeBody string) (mpr, dockerDir string) { + t.Helper() + appDir := filepath.Join(t.TempDir(), "LegacyApp") + dockerDir = filepath.Join(appDir, ".docker") + if err := os.MkdirAll(dockerDir, 0755); err != nil { + t.Fatal(err) + } + mpr = filepath.Join(appDir, "app.mpr") + if err := os.WriteFile(mpr, nil, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dockerDir, "docker-compose.yml"), []byte(composeBody), 0644); err != nil { + t.Fatal(err) + } + return mpr, dockerDir +} + +const legacyCompose = "# mxcli-template-version: 2\nservices:\n mendix:\n image: x\n" + +func readName(t *testing.T, dockerDir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dockerDir, "docker-compose.yml")) + if err != nil { + t.Fatal(err) + } + n, ok := existingComposeName(data) + if !ok { + t.Fatalf("regenerated compose file has no name:\n%s", data) + } + return n +} + +func TestInit_ForceOnLegacyProjectKeepsOldStackName(t *testing.T) { + // Renaming the stack would make Compose create new, empty volumes and + // orphan the old Postgres data. A regeneration of a file that had no name + // must therefore pin the name Compose was using: the folder name, "docker". + mpr, dockerDir := legacyProject(t, legacyCompose) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"docker"` { + t.Errorf("name after --force on a legacy project = %s, want \"docker\"", got) + } + out := buf.String() + for _, want := range []string{"docker", "mxcli-legacyapp", "empty"} { + if !strings.Contains(strings.ToLower(out), want) { + t.Errorf("output should explain the kept name and how to opt in (missing %q):\n%s", want, out) + } + } +} + +func TestInit_ForceOnLegacyCustomOutputDirKeepsFolderDerivedName(t *testing.T) { + appDir := filepath.Join(t.TempDir(), "App") + custom := filepath.Join(appDir, "My Stack.Dir") + if err := os.MkdirAll(custom, 0755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(custom, "docker-compose.yml"), []byte(legacyCompose), 0644) + mpr := filepath.Join(appDir, "app.mpr") + os.WriteFile(mpr, nil, 0644) + + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, OutputDir: custom, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, custom); got != `"mystackdir"` { + t.Errorf("name = %s, want \"mystackdir\" (what Compose derived from the folder)", got) + } +} + +func TestInit_ForceOnLegacyHonoursComposeProjectNameInEnv(t *testing.T) { + // The workaround for #1364 was COMPOSE_PROJECT_NAME in .env; --force + // rewrites .env, so the value has to move into the compose file or the + // stack is renamed behind the user's back. + mpr, dockerDir := legacyProject(t, legacyCompose) + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("APP_PORT=9000\nCOMPOSE_PROJECT_NAME=\"shop\"\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\" (from the old .env)", got) + } +} + +func TestInit_ForceCarriesEnvProjectNameOverExplicitName(t *testing.T) { + // Compose prefers COMPOSE_PROJECT_NAME over name:, so a stack with both is + // really named by .env. --force rewrites .env; the real name must survive. + mpr, dockerDir := legacyProject(t, "# mxcli-template-version: 3\nname: \"mxcli-a\"\nservices:\n") + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("COMPOSE_PROJECT_NAME=shop\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\" (the name Compose was really using)", got) + } + env, _ := os.ReadFile(filepath.Join(dockerDir, ".env")) + if strings.Contains(string(env), "COMPOSE_PROJECT_NAME") { + t.Error(".env should be the regenerated template") + } +} + +func TestInit_NewComposeFileHonoursEnvProjectName(t *testing.T) { + // compose file deleted, .env (with the old workaround) still there + mpr, dockerDir := legacyProject(t, legacyCompose) + os.Remove(filepath.Join(dockerDir, "docker-compose.yml")) + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("COMPOSE_PROJECT_NAME=shop\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\"", got) + } +} + +func TestInit_ForceKeepsExplicitName(t *testing.T) { + // A user who opted in (or chose their own name) keeps it across --force. + mpr, dockerDir := legacyProject(t, "# mxcli-template-version: 3\nname: my-own\nservices:\n") + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != "my-own" { + t.Errorf("name = %s, want my-own", got) + } +} + +func TestInit_NoForceNeverRenames(t *testing.T) { + // Without --force the compose file is left alone, and a deleted .env being + // recreated must not introduce a project name either. + mpr, dockerDir := legacyProject(t, legacyCompose) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(dockerDir, "docker-compose.yml")) + if string(data) != legacyCompose { + t.Errorf("compose file must be untouched without --force:\n%s", data) + } + env, _ := os.ReadFile(filepath.Join(dockerDir, ".env")) + if strings.Contains(string(env), "COMPOSE_PROJECT_NAME") { + t.Error(".env must not set COMPOSE_PROJECT_NAME (the compose file's name: is the single source)") + } +} diff --git a/cmd/mxcli/docker/init.go b/cmd/mxcli/docker/init.go index 4f30d69363..8377c73dbc 100644 --- a/cmd/mxcli/docker/init.go +++ b/cmd/mxcli/docker/init.go @@ -49,7 +49,7 @@ func Init(opts InitOptions) error { // Write docker-compose.yml composePath := filepath.Join(dockerDir, "docker-compose.yml") - if err := writeTemplate(composePath, "templates/docker-compose.yml", opts.Force, w); err != nil { + if err := writeComposeFile(composePath, dockerDir, opts, w); err != nil { return err } @@ -101,6 +101,50 @@ func Init(opts InitOptions) error { return nil } +// writeComposeFile writes docker-compose.yml with its Compose project name +// filled in. A new file gets a name of its own; regenerating an existing one +// keeps the name the stack already had (see composename.go), because a stack's +// name is the key to its containers and its database volume. +func writeComposeFile(composePath, dockerDir string, opts InitOptions, w io.Writer) error { + if !opts.Force && fileExists(composePath) { + fmt.Fprintf(w, " Skipped %s (already exists)\n", composePath) + return nil + } + + data, err := templatesFS.ReadFile("templates/docker-compose.yml") + if err != nil { + return fmt.Errorf("reading compose template: %w", err) + } + // Decide before the file is overwritten: the old file is the evidence. + name := decideComposeName(opts.ProjectPath, dockerDir, composePath) + rendered, err := renderComposeTemplate(data, name) + if err != nil { + return err + } + if err := os.WriteFile(composePath, rendered, 0644); err != nil { + return fmt.Errorf("writing %s: %w", composePath, err) + } + + fmt.Fprintf(w, " Created %s\n", composePath) + switch name.Source { + case "new": + fmt.Fprintf(w, " Compose project name: %s (containers %s-db-1, %s-mendix-1; volume %s_postgres-data)\n", + name.Name, name.Name, name.Name, name.Name) + case "env": + fmt.Fprintf(w, " Kept Compose project name %q from COMPOSE_PROJECT_NAME in the previous .env (which is rewritten); it is now set by 'name:' in the compose file\n", name.Name) + case "kept": + fmt.Fprintf(w, " Kept Compose project name %q from the previous docker-compose.yml\n", name.Name) + case "legacy": + suggested := ComposeProjectNameFor(opts.ProjectPath) + fmt.Fprintf(w, " Kept Compose project name %q: the previous docker-compose.yml had no 'name:', so Compose used the folder name\n", name.Name) + fmt.Fprintf(w, " and your containers and database volume (%s_postgres-data) are still found under it.\n", name.Name) + fmt.Fprintf(w, " Other mxcli projects using %q share that name, and with it their containers and volumes.\n", name.Name) + fmt.Fprintf(w, " To isolate this project, change 'name:' in %s to e.g. %q. Compose then creates\n", composePath, suggested) + fmt.Fprintf(w, " NEW containers and an EMPTY database; the old volume stays untouched (back it up first if you need its data).\n") + } + return nil +} + // writeTemplate writes an embedded template file to disk. func writeTemplate(destPath, templateName string, force bool, w io.Writer) error { if !force && fileExists(destPath) { diff --git a/cmd/mxcli/docker/templates/docker-compose.yml b/cmd/mxcli/docker/templates/docker-compose.yml index 7ed4acad53..52da7b5163 100644 --- a/cmd/mxcli/docker/templates/docker-compose.yml +++ b/cmd/mxcli/docker/templates/docker-compose.yml @@ -1,7 +1,18 @@ -# mxcli-template-version: 2 +# mxcli-template-version: 3 # Regenerate with `mxcli docker init --force` when mxcli reports this file is # out of date — template fixes (e.g. the OQL dev-servlet flags) only reach an # existing .docker/ when the compose file is rewritten. +# +# Compose project name: prefixes every container, network and volume of this +# stack (-db-1, _postgres-data), so each project needs its own — +# before v3 it was the ".docker" folder name, i.e. "docker" for every project, +# and the stacks and databases of different apps collided (mxcli#1364). +# The name identifies the stack's data: change it and Compose starts NEW +# containers with an EMPTY database volume, leaving the old one behind. Keep it +# stable; `mxcli docker init --force` keeps whatever is here. `docker compose -p` +# and COMPOSE_PROJECT_NAME override it. +name: __COMPOSE_PROJECT_NAME__ + services: mendix: image: eclipse-temurin:21-jre diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index e103d8fa76..f82210201d 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -257,13 +257,15 @@ func init() { "-- all of them, and CONTINUE is fine on DECLARE, SET, RETRIEVE, DELETE\n" + "-- and CALL MICROFLOW.\n" + "--\n" + - "-- List operations and aggregates ($x = head $l, $n = count $l) have\n" + - "-- no error handling in Mendix at all -> MDL077.\n" + + "-- List operations, aggregates, CREATE LIST OF, ADD, REMOVE, CAST and\n" + + "-- SHOW HOME PAGE take a custom handler but not CONTINUE -> MDL076.\n" + + "-- ROLLBACK takes every form.\n" + "--\n" + "-- IN A NANOFLOW a statement with no clause aborts the flow on error, and\n" + "-- there is no transaction to roll back. DECLARE, SET, RETRIEVE and DELETE\n" + - "-- take every clause. CREATE, COMMIT, CALL NANOFLOW and CALL MICROFLOW take\n" + - "-- only ON ERROR WITHOUT ROLLBACK BEGIN ... END ERROR. CHANGE, LOG, SHOW\n" + + "-- take every clause, and so does ROLLBACK. CREATE, COMMIT, CALL NANOFLOW,\n" + + "-- CALL MICROFLOW, list operations, aggregates, CREATE LIST OF, ADD, REMOVE\n" + + "-- and CAST take only ON ERROR WITHOUT ROLLBACK BEGIN ... END ERROR. CHANGE, LOG, SHOW\n" + "-- PAGE, CLOSE PAGE, SHOW MESSAGE and VALIDATION FEEDBACK take none. Every\n" + "-- other form is CE6035 and is refused (MDL091).\n" + "--\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index bacd54c402..4744e9ea5c 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -624,7 +624,7 @@ it is for pages. | Execute DB query | `$Result = execute database query Module.Conn.Query;` | 3-part name; supports DYNAMIC, params, CONNECTION override | | Import mapping | `[$Var =] import from mapping Module.IMM($SourceVar) [all\|first\|limit [offset ]];` | Apply import mapping to string variable. Trailing clause is Studio Pro's Range; omitted = infer from the mapping's root. `first` binds one OBJECT (`limit 1` is a one-element LIST). Mendix rejects `offset` on a non-list mapping (CE6100), and `first` on an object-rooted one throws at runtime — both MDL-MAP04 | | Export mapping | `$Var = export to mapping Module.EMM($EntityVar);` | Apply export mapping to entity, returns string | -| Error handling | `... on error continue\|rollback\|[without rollback] begin handler end error;` | Goes on the activity that may fail — including `declare`, `set`, `change`, `log`, `show page`, `close page`, `show message` and `validation feedback`, which gained it in mendixlabs/mxcli#1078 so a Studio Pro handler survives DESCRIBE. `on error continue` is refused (MDL076) where Mendix raises CE6035: create, change, commit, log, show page, close page, show message, validation feedback — a custom `begin handler end error` is accepted on all of them. The list-operation and aggregate forms of `set` have no error handling at all (MDL077). Not supported on EXECUTE DATABASE QUERY. **In a nanoflow** only `declare` and `set` take a clause at all — `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 there in every form, and are refused. A handler that does not end in `return`/`raise error` merges back into the main flow, so a later variable is out of scope on the error path (CE0108) | +| Error handling | `... on error continue\|rollback\|[without rollback] begin handler end error;` | Goes on the activity that may fail — including `declare`, `set`, `change`, `log`, `show page`, `close page`, `show message` and `validation feedback`, which gained it in mendixlabs/mxcli#1078 so a Studio Pro handler survives DESCRIBE. `on error continue` is refused (MDL076) where Mendix raises CE6035: create, change, commit, log, show page, close page, show message, validation feedback — a custom `begin handler end error` is accepted on all of them. List operations, aggregates, `create list of`, `add`/`remove`, `cast` and `show home page` take a custom handler and `on error rollback` but not `continue` (MDL076); `rollback` takes every form (mendixlabs/mxcli#1252, measured on 11.12.3). Not supported on EXECUTE DATABASE QUERY. **In a nanoflow** `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 in every form, and are refused; create, commit, the calls, the list activities and `cast` take only `on error without rollback begin … end error`. A handler that does not end in `return`/`raise error` merges back into the main flow, so a later variable is out of scope on the error path (CE0108) | | Re-raise the error | `raise error;` | **Inside an `on error begin … end error` handler only.** The error event re-raises the error being handled, so Mendix needs one in scope; Studio Pro will not draw the shape and mxbuild rejects it with **CE0710** "The main flow cannot join an error flow or end in an error event". On the main flow — at any nesting depth, and in a rule too — it is **MDL084**. Mendix has no main-flow "throw": call a Java action that throws | | Named join point | `merge