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
2 changes: 2 additions & 0 deletions .claude/skills/fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ to the symptom table below, so the next similar issue costs fewer reads.
| A built-in widget action (`actionbutton`/`container`/`linkbutton` `Action: microflow M.Flow(Param: $x)`) **silently drops the argument** on the **default (modelsdk)** engine — `describe` shows `Action: microflow M.Flow` (no args), the flow runs with no parameter, and a required-param flow never fires so the button/row no-ops at runtime. `mxcli check`/`docker check`/`docker build` all pass (0 errors) — silent break. Legacy persists it (the workaround) | `clientActionToGen`'s `MicroflowClientAction` case built the `Forms$MicroflowSettings` from the microflow **name only** (`microflowSettingsToGen(x.MicroflowName)`), never serializing `x.ParameterMappings` — the executor builds them correctly into the model, the writer threw them away. (NOT the same as `custom-widgets.md`'s "action mapping too complex for auto-mapping" — that's about *pluggable-widget action-typed properties* in def.json generation, a different path.) | `mdl/backend/modelsdk/widget_write.go` (`microflowSettingsToGen`) | Give `microflowSettingsToGen` the mappings param; emit one `genPg.MicroflowParameterMapping` each — `SetParameterQualifiedName("<MicroflowQName>.<ParamName>")` (BY_NAME) + `SetExpression(variable-or-expression)` — mirroring the legacy `writer_widgets_action.go` BSON. The client-action caller passes `x.ParameterMappings`; the microflow-datasource callers pass `nil`. Verified: default-engine describe now keeps `(Param: $x)`; a type-correct page = 0 mxbuild errors (both engines give the *same* CE0117 on a type-mismatched arg — that's a model error, not the writer). Tests: `TestClientActionToGen_MicroflowParameterMappings`; bug-test `mdl-examples/bug-tests/bug1-widget-action-param-mapping.mdl`. Nanoflow widget actions were a separate gap (the whole `NanoflowClientAction` case was missing) — now fixed in Bug 2 below. Bug 1 |
| A microflow `declare $x Module.Entity [= $obj];` (object/entity-typed local variable) passes `mxcli check` but mxbuild rejects it with **CE0053** "Selected type is not allowed" (+ CE0038 "Value required", + CE7247 on a following `set`) — a silent trap. The declare-entity attribute qualifier is a red herring: the whole construct is invalid, bare or initialized | Mendix's Create Variable activity (what `declare` maps to) only holds **primitive** types; there is no object/list Create Variable form (same restriction as MDL040 for lists). Objects must come from a parameter, a `retrieve … limit 1`, a `create` object, or a loop iterator. The check was missing; the microflow parser records a bare `Module.X` declare type as the ambiguous `TypeEnumeration` (`EnumRef`, `ExplicitEnum=false`), an explicit `Enumeration(Module.X)` sets `ExplicitEnum=true` | `mdl/executor/validate_microflow.go` (`walkBody` `*ast.DeclareStmt` case, next to the MDL040 list check) | Add **MDL043**: flag `Type.Kind == TypeEntity \|\| (TypeEnumeration && EnumRef != nil && !ExplicitEnum)`; error points to parameter/retrieve/create/loop and notes "if it's an enum, write `Enumeration(...)`". Reliable connection-free (no backend) because bare object names resolve to a distinct AST shape from explicit enums. Also fix the skills that wrongly taught `declare $x Module.Entity;` / `declare $x as …` as valid (`write-microflows.md`, `cheatsheet-variables.md`, `cheatsheet-errors.md`, `check-syntax.md`, `patterns-crud.md`, `patterns-data-processing.md`, `business-events.md`, `resolve-forward-references.md`, `migrate-k2-nintex.md`, `README.md`). Test: `TestValidateMicroflow_DeclareObjectIsRejected`; bug-test `mdl-examples/bug-tests/declare-object-variable-rejected.fail.mdl`. mxbuild-confirmed (11.12.0) |
| No way to change an existing **enumeration value's caption** in place — `alter enumeration` had only `ADD`/`RENAME`/`DROP VALUE` + `SET COMMENT`; `RENAME VALUE X TO Y` changes the *name*, not the caption. The only route was drop + recreate, which fails while the enum is referenced by an attribute | Missing grammar action + AST op + executor case — a full-stack gap, not a code bug | `mdl/grammar/domains/MDLDomainModel.g4` (`alterEnumerationAction`) → `mdl/ast/ast_enumeration.go` (`AlterEnumOp`) → `mdl/visitor/visitor_enumeration.go` (`ExitAlterEnumerationAction`) → `mdl/executor/cmd_enumerations.go` (`execAlterEnumeration`) | Add `MODIFY VALUE IDENTIFIER CAPTION STRING_LITERAL` (reuses existing `MODIFY`/`CAPTION` tokens, so no lexer change); add `AlterEnumModifyCaption` op reusing the `Caption` field; executor finds the value by name and replaces only the `en_US` translation (preserves the value's ID + other locales). Re-captions in place, so it works while referenced (mxbuild 0 errors) — no drop needed. Value names in `alter` must be plain identifiers (a reserved-word-named value like `Created` can't be targeted — pre-existing, shared by ADD/RENAME/DROP). Tests: `TestAlterEnumeration_ModifyValueCaption` (visitor) + `TestAlterEnumeration_ModifyValueCaption_Mock` (executor); example in `01-domain-model-examples.mdl` |
| A page with a native **LISTVIEW** over a `database from` (XPath) datasource passes `mx check` but **crashes the browser client at runtime** and redirects to login: `TypeError: Cannot read properties of undefined (reading 'length')` at `processResult` → `retrieveByXPath`. Pluggable Gallery/DataGrid2 database sources are fine | The serialized `Forms$ListViewXPathSource` omitted the arrays the client reads `.length` of. **Codec (default):** `Forms$ListViewSearch` emitted without its `SearchRefs` list — the encoder drops an empty, never-`Set` PartList unless the `$Type` is in `RegisterTypeDefaults` (GridSortBar.SortItems was registered; ListViewSearch was not). **Legacy:** wrote a bogus `Forms$ListViewSort` + a `Paths` key (renamed `SearchRefs` in 7.11.0) and no `Forms$GridSortBar`. NOT a `SortItems` marker issue — an empty `[2]` compiles fine when search is off; the crash is the absent `SearchRefs`. Diagnose by building a Deploy target and reading the compiled `deployment/web/pages/<Page>.js` (the client model) + `mxcli bson dump --type page` on the source | `mdl/backend/modelsdk/widget_write.go` (`init` RegisterTypeDefaults) + `sdk/mpr/writer_widgets_display.go` (`serializeListViewDataSource`, `emptyListViewXPathSource`) | Codec: `RegisterTypeDefaults("Forms$ListViewSearch", {MandatoryLists: []string{"SearchRefs"}})` (emits empty marker-3 list). Legacy: emit `Forms$GridSortBar`/`SortItems` (mirror `SerializeCustomWidgetDataSource`) + `Forms$ListViewSearch`/`SearchRefs` + `ForceFullObjects`; drop the bogus `Sort`/`Paths`. Tests: `TestListViewSourceToGen_SearchRefsEmitted` (codec, encode-level), `TestSerializeListViewDataSource_Database` + `TestEmptyListViewXPathSource_Shape` (legacy). Repro `mdl-examples/bug-tests/listview-database-source-searchrefs.mdl` |
| Editing `themesource/**/main.scss` (or `theme/web/main.scss`) while `mxcli run --local` serves on `:8080` keeps showing **old styles** — reads exactly like a stale compiled-CSS cache. `rm -rf theme-cache/ .mendix-cache/ deployment/` "fixes" it only because a restart came with it | THREE distinct causes, none a CSS cache: (1) **no `--watch` = no watcher at all** — `mxbuild --serve` only rebuilds on a `/build` request (startup, or a watch tick), so a save changes nothing; (2) **stale process silently adopted** — a leftover serve/runtime on the ports answers the startup readiness probes (`waitReady`/`waitAdminReady` only check "port answers"), so a new run attaches to the OLD process and its own child is torn down by `defer`; a backgrounded `run --local` whose wrapping shell exited non-zero dies while its serve+runtime keep serving; (3) the theme source was **watched by nothing** — the `--watch` signal was model-only (`.mpr`+`mprcontents/`). The incremental theme step itself is FINE: one `/build` after an scss **content** edit does rewrite `theme-cache/web/theme.compiled.css` (verified), so there is no cache to clear | `cmd/mxcli/docker/runlocal.go` — `checkTargetPortsFree` (guard), `themeSourceMTime`/`sourceMTime` (watch signal), `watchAndApply` (generation log) | (2) Refuse to boot when `:8080/:8090/:6543` already answer, with an actionable message (never auto-kill — user's call). (3) Add `theme/`+`themesource/` scss/css/js to the `--watch` mtime-poll signal (`sourceMTime` = max(model, theme)); poll-based so it's container-safe (unlike the rollup chokidar/inotify web-client watcher). Log a build-generation counter (`build #2`) so "did it take?" is answerable. Docs: `docs-site/src/tools/run-local.md` + skill `run-local.md` — "SCSS needs a rebuild (`--watch` or clean restart), never a cache-clear; kill the old serve/runtime first". Tests `TestThemeSourceMTime_WatchesThemeAndThemesource`, `TestCheckTargetPortsFree` |

---

Expand Down
36 changes: 34 additions & 2 deletions .claude/skills/mendix/run-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,40 @@ mxcli run --local -p app.mpr --watch
mxcli exec add-page.mdl -p app.mpr
```

`--watch` observes the project directory (MPR v1 and v2 layouts), ignoring
`deployment/`, `.git`, and `node_modules`.
`--watch` observes two source trees and rebuilds when either changes: the **model
source** (`.mpr` + `mprcontents/`, v1 and v2 layouts) and the **theme source**
(`theme/` and `themesource/<module>/web/` — SCSS/CSS/JS). It ignores build output
(`deployment/`, `theme-cache/`, `.mendix-cache/`, `.mxcli/`). Both signals are mtime
polling, so they work on container filesystems where inotify is silent. Each apply is
logged with a build-generation counter (`build #2`, …) so you can confirm a change
landed.

## Editing themes (SCSS) — rebuild, never clear caches

A theme edit (`theme/web/main.scss`, module SCSS) needs a **rebuild**, not a
cache-clear. With `--watch`, just save the `.scss` — the theme source is watched and
the loop rebuilds and hot-applies. Without `--watch`, nothing is watched, so the save
does nothing until you restart `run --local` (or re-run with `--watch`). `mxbuild
--serve` recompiles the theme on its next build and correctly picks up SCSS content
changes, so **never** `rm -rf theme-cache/ .mendix-cache/ deployment/` — clearing
caches is a red herring.

## "My edit didn't show up" — stale process, not stale cache

`run --local` refuses to boot when its ports (8080/8090/6543) are already answering,
because a leftover `run --local` / `mxbuild --serve` / runtime would otherwise be
silently adopted and keep serving old output (it looks like a cache but is a stale
**process**). If a background `run --local` died while its serve+runtime kept serving,
recover with:

```bash
pgrep -af 'mxbuild --serve|runtimelauncher|mxcli run' # find them
kill <pid> # stop each
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080 # want 000
```

Launch `run --local` as the **sole** command in its invocation (don't chain a trailing
`sleep`/`curl` whose non-zero exit can kill the backgrounded run); poll separately.

## Flags

Expand Down
108 changes: 101 additions & 7 deletions cmd/mxcli/docker/runlocal.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,43 @@ func pingTCP(hostPort string, timeout time.Duration) error {
return nil
}

// checkTargetPortsFree refuses to boot when any of the loop's ports is already
// answering, which almost always means a previous `mxcli run --local` (or a
// stray mxbuild --serve / runtime) is still alive. Without this guard the boot
// "succeeds" against the stale process: the readiness probes only check that the
// port answers (StartServe.waitReady / runtime waitAdminReady), so mxcli adopts
// the old serve/runtime, the fresh JVM that failed to bind is torn down by
// defer, and the old process keeps serving stale output — reading exactly like a
// stale cache. Refusing with an actionable message turns that silent failure
// into a clear one. We only *detect* here (never kill): reaping someone else's
// process is the user's call.
func checkTargetPortsFree(o LocalRunOptions) error {
host := "127.0.0.1"
type p struct {
port int
role string
flag string
}
for _, c := range []p{
{o.AppPort, "app", "--app-port"},
{o.AdminPort, "admin API", "--admin-port"},
{o.ServePort, "mxbuild serve", "--serve-port"},
} {
hostPort := fmt.Sprintf("%s:%d", host, c.port)
if err := pingTCP(hostPort, 500*time.Millisecond); err == nil {
return fmt.Errorf("port %d (%s) is already in use — a previous 'mxcli run --local' "+
"or a stray mxbuild --serve/runtime is likely still serving on it.\n"+
" A stale process is silently adopted otherwise, so edits appear to do nothing (looks like a stale cache — it isn't).\n"+
" Free the ports, then retry:\n"+
" pgrep -af 'mxbuild --serve|runtimelauncher|mxcli run' # find them\n"+
" kill <pid> # stop each; confirm with: curl -s -o /dev/null -w '%%{http_code}' http://%s:%d (want 000)\n"+
" Or run on different ports with %s (and --admin-port/--serve-port).",
c.port, c.role, host, o.AppPort, c.flag)
}
}
return nil
}

// webClientSourceMTime returns the newest mtime of the browser-client *source*
// under <deployDir>/web, excluding the rollup output (dist/) and the build log.
// A page/widget/theme edit bumps it (and needs a client re-bundle); a
Expand Down Expand Up @@ -238,13 +275,65 @@ func projectSourceMTime(projectPath string) time.Time {
return newest
}

// themeSourceMTime returns the newest mtime of the app's *theme source* — the
// SCSS/CSS/JS the designer edits under theme/ (app-level: main.scss,
// custom-variables.scss, …) and themesource/<module>/web/ (per-module). These
// live outside the .mpr/mprcontents tree, so projectSourceMTime never sees them;
// without this a theme edit triggers no rebuild even under --watch, and the app
// keeps serving the old styles (the "SCSS cache" symptom — really a missing
// watch). mxbuild --serve recompiles the theme on the next /build, so surfacing
// the edit as a change signal is all that's needed. Walk is mtime-polling (same
// as projectSourceMTime), so it is reliable on container filesystems where
// inotify is not — no watcher fd involved.
func themeSourceMTime(projectPath string) time.Time {
dir := filepath.Dir(projectPath)
var newest time.Time
for _, sub := range []string{"theme", "themesource"} {
root := filepath.Join(dir, sub)
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
switch strings.ToLower(filepath.Ext(path)) {
case ".scss", ".css", ".js", ".json":
if info, err := d.Info(); err == nil && info.ModTime().After(newest) {
newest = info.ModTime()
}
}
return nil
})
}
return newest
}

// sourceMTime is the combined --watch change signal: the newer of the model
// source (projectSourceMTime) and the theme source (themeSourceMTime). Either a
// model edit or a theme edit re-triggers the build.
func sourceMTime(projectPath string) time.Time {
m := projectSourceMTime(projectPath)
if t := themeSourceMTime(projectPath); t.After(m) {
return t
}
return m
}

// RunLocal boots the warm local dev loop: resolve tooling, start mxbuild --serve
// and a standalone runtime, do the first build+apply, then (with Watch) rebuild
// and hot-apply on every project change until interrupted.
func RunLocal(opts LocalRunOptions) error {
opts.applyDefaults()
w, stderr := opts.Stdout, opts.Stderr

// 0. Refuse fast if the loop's ports are already taken (a stale run/serve/
// runtime). Skipped for SetupOnly, which never boots a server. This is the
// single-instance guard: without it a stale process is silently adopted and
// keeps serving old output.
if !opts.SetupOnly {
if err := checkTargetPortsFree(opts); err != nil {
return err
}
}

// 1. Detect the project's Mendix version.
fmt.Fprintln(w, "Detecting project version...")
reader, err := mpr.Open(opts.ProjectPath)
Expand Down Expand Up @@ -526,8 +615,12 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, w
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)

fmt.Fprintln(w, "Watching for changes (Ctrl-C to stop)...")
last := projectSourceMTime(opts.ProjectPath)
fmt.Fprintln(w, "Watching model + theme source for changes (serving build #1; Ctrl-C to stop)...")
last := sourceMTime(opts.ProjectPath)
// gen is the served build generation — a monotonic counter surfaced on every
// apply so "did my change take?" is answerable from the log without guessing.
// The initial boot build is generation 1.
gen := 1
ticker := time.NewTicker(opts.PollInterval)
defer ticker.Stop()

Expand All @@ -537,12 +630,13 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, w
fmt.Fprintln(w, "\nShutting down...")
return nil
case <-ticker.C:
now := projectSourceMTime(opts.ProjectPath)
now := sourceMTime(opts.ProjectPath)
if !now.After(last) {
continue
}
last = now
fmt.Fprintln(w, "Change detected, rebuilding...")
gen++
fmt.Fprintf(w, "Change detected, rebuilding (build #%d)...\n", gen)
start := time.Now()

// Capture the client-bundle generation and the web-source mtime before
Expand Down Expand Up @@ -584,13 +678,13 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, w
}
client := ""
if bundled {
client = ", client re-bundled"
client = fmt.Sprintf(", client re-bundled (gen %d)", watcher.Generation())
}
fmt.Fprintf(w, " applied via %s in %s%s -> %s\n", action, time.Since(start).Round(time.Millisecond), client, rt.AppURL())
fmt.Fprintf(w, " build #%d applied via %s in %s%s -> %s\n", gen, action, time.Since(start).Round(time.Millisecond), client, rt.AppURL())
maybeScreenshot(opts, rt)
// Refresh the baseline AFTER the apply so an edit made mid-build is
// still caught on the next tick.
last = projectSourceMTime(opts.ProjectPath)
last = sourceMTime(opts.ProjectPath)
}
}
}
Loading
Loading