diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 1b0c96179..f4b24d120 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -20,18 +20,32 @@ to the symptom table below, so the next similar issue costs fewer reads. 6. After the fix: **add a new row** to the table if the symptom is not already covered. **Append it at the END of the table, never at the top.** -> **Why the end matters.** Every bug fix touches this one file, and for a long time -> new rows went in directly under the header. Two branches fixing two unrelated bugs -> therefore inserted at the *same line*, and git cannot merge that — it is a conflict -> by construction, not by bad luck. It cost five separate resolution rounds in one -> week, and each round risks dropping someone's row. +> **Conflicts are handled by git, not by where you insert.** Every bug fix touches +> this one file, so two branches fixing unrelated bugs write to the same place and git +> raises a conflict. That cost five resolution rounds in one week when rows went in +> under the header — and moving them to the end did **not** fix it: both sides still +> append to the same line, so the collision moved with the convention. PRs #76, #77 +> and #78 each hit it again afterwards. > -> Appending puts each branch's insert at a different offset, which git merges without -> help. The table is unordered — it is looked up by matching a symptom, not by -> reading top to bottom — so position carries no meaning and appending costs nothing. +> The actual fix is in `.gitattributes`: > -> The rows above pre-date this convention and are left as they are; reordering them -> would conflict with every open branch at once, which is the problem, not the fix. +> ``` +> .claude/skills/fix-issue.md merge=union +> ``` +> +> git's built-in `union` driver keeps **both** sides of a conflicting hunk instead of +> raising a conflict. Two fixes that each append a row now merge with no intervention +> and both rows present — verified by merging a simulated later fix into an open +> branch. Where you insert no longer affects merging at all. +> +> Append at the end anyway, for a human reason: it keeps a fix's diff readable and the +> table roughly chronological. The table is looked up by matching a symptom, not read +> top to bottom, so position carries no meaning. +> +> **One caveat.** `union` applies to the whole file, so two branches editing the same +> *prose* line would silently keep both rather than conflict — a visible duplicated +> line, not corruption. If that starts happening, split the table into its own file so +> `union` covers only append-only content. --- @@ -262,6 +276,10 @@ to the symptom table below, so the next similar issue costs fewer reads. | A datagrid **column** with an explicit empty caption (`Caption: ''`) passes `mxcli check` but `mx check` rejects the page with **CE0463** "The definition of this widget has changed" on the Data grid 2 (error points at the widget version, not the caption). Omitting the caption, or a non-empty string, both build clean | The pluggable widget engine's column-header fallback treated a **present-but-empty** header property as "has header" and skipped the attribute-name default — so `Caption: ''` emitted an empty header (which Studio Pro rejects) while an **absent** caption got the fallback. The keyword datagrid path already handled it (`if caption == "" { caption = col.Attribute }` in `datagrid_column.go`) | `mdl/executor/widget_engine.go` (`applyColumnHeaderFallback`) | **Write-path fix.** Detect an empty header (a `texttemplate` op with empty `TextTemplate` and no `Parameters`) and treat it like an absent one: fill it **in place** with the bound attribute's leaf name (not appended — that would duplicate the `header` prop). A header WITH params (`Caption: '{1}'`) is left untouched. Result: `Caption: ''` now behaves like omitting it. **Round 2 (custom-content columns):** the first fix left a column with **no bound attribute** (an action/custom-content column) untouched — it had nothing to derive a header from, so an empty OR absent caption still tripped CE0463 (a custom-content column requires a non-empty header). Fixed by falling back to the **column's own name** when there's no attribute, and **gating the whole fallback on the item template having a `header` slot** (`mapping.ItemProperties`) so header-less object-list items (chart series, accordion groups) are never given a spurious header. `applyColumnHeaderFallback(spec, columnName, hasHeaderSlot)`. Test `TestApplyColumnHeaderFallback` (cases 1–8); examples `ledger-54-empty-column-caption.mdl` + custom-content verified via exec. **Verified: `mx check` → 0 errors on 11.12.1 for attribute columns AND custom-content columns (empty + absent caption)** (previously CE0463). Ledger finding #54 (custom-content columns) | | Follow-up to round 2 (#25 re-test): the subscriber is registered but `runtime.log` **still** holds only the 4 JVM-banner lines — a probe microflow's `log info/warning/error` and a forced runtime exception produce **zero** lines. Isolation test: boot+subscriber → 0 probe lines; then call `start_logging` → 6 lines | A standalone runtime boots with logging **not started**, so a registered subscriber sits **inert** — nothing is delivered until the logging subsystem is activated. `create_log_subscriber` alone is necessary but not sufficient | `cmd/mxcli/docker/runtime_controller.go` (`configureRuntimeLogging` — renamed from `attachFileLogSubscriber`; now also calls `start_logging`) | After `create_log_subscriber`, call `CallM2EE(c.opts, "start_logging", nil)` in the same step (order: create subscriber → start_logging). Treat an "already started" response as success (`start` re-runs on the DB-update retry / restart paths on a still-running JVM). Still best-effort (warn to Stdout, never fail an up runtime). Test `TestStart_StartLoggingAlreadyStartedIsSuccess`; `TestStart_AttachesLogSubscriber` now asserts the `[start, create_log_subscriber, start_logging]` sequence. **Diagnosis pattern**: when a registered sink receives nothing, check whether the subsystem that feeds it is even *running* — registration ≠ activation. Findings #25 (round 3) | | `mxcli new --version X` prints "Resolving MxBuild X..." and then produces a project at a **different** Mendix version — silently. Every later step (init, mxbuild, runtime, `run --local`) follows the wrong version | `ResolveMxForNewProject` delegated to `ResolveMxForVersion`, whose last resort is `AnyCachedMxPath()` — *any* cached mx, of any version. That fallback is fine when the project already exists and its version is a preference; for `new` the requested version **is** the output, because `mx create-project` stamps the project with the version of the binary that ran it | `cmd/mxcli/docker/check.go` (`localMxForVersion`, `ResolveMxForNewProject`) + `cmd/mxcli/cmd_new.go` (postcondition) | Resolve **exactly** the requested version for `new` (exact Studio Pro install → exact versioned install path → exact download cache; **not** PATH, which carries no version guarantee), and download otherwise. Then check the postcondition: reopen the created `.mpr`, compare `ProductVersion` to `--version`, and fail loudly on a mismatch — resolution bugs are invisible without it. **Generalisable**: when a flag names the version/identity of the artifact being produced, a "close enough" local substitute is never valid, and the produced artifact should be verified against the request rather than the resolution trusted. Found while reproducing #812 in a browser — cost a full project rebuild before it was noticed | +| A **DataView** property parses, passes `mxcli check`, and has no effect — `FormOrientation: Vertical` (#762) or `showFooter: true` (#813). `FormOrientation` works under `--engine legacy` | Two different causes that look identical. (a) `FormOrientation` has no BSON field: Studio Pro's radio **is** `LabelWidth` (0=Vertical, 3=Horizontal default). Only the legacy writer translated it; the modelsdk writer emitted `LabelWidth` solely when set explicitly, so the orientation was read into the model and dropped — the #812 shape, a model field no active-engine writer reads. (b) `ShowFooter` was only ever set implicitly by a `footer { … }` block; the property sat in the validator allow-list, so it parsed and was discarded | `sdk/pages/pages_widgets_data.go` (`ResolvedLabelWidth`), `mdl/backend/modelsdk/widget_write.go`, `sdk/mpr/writer_widgets_display.go`, `mdl/executor/cmd_pages_builder_v3_widgets.go` | Put the derivation **on the model** (`ResolvedLabelWidth`) so both writers share one definition instead of one owning it, and emit `LabelWidth` unconditionally. For the property, read it explicitly and let it win over the implicit block in both directions. **Trap**: `WidgetV3.GetBoolProp` is case-SENSITIVE and accepts only a real `bool`, unlike `GetStringProp` — so `showFooter: true` read as `false` even after the key was found. Coerce from the looked-up value and refuse a nonsense one instead of defaulting to false. Repro `mdl-examples/bug-tests/762-813-dataview-properties.mdl`. Issues #762, #813 | +| Every mxcli-authored page carries a container nobody asked for — a `Forms$DivContainer` named `conditionalVisibilityWidget` wrapping the page's top-level widgets. Creating a single button yields a button **and** a container | The builder wrapped each non-empty layout placeholder, because `pages.LayoutCallArgument` declared a **single** `Widget` field while the BSON `Forms$FormCallArgument` carries a **`Widgets` array**. The wrapper existed only to squeeze N widgets through a 1-widget field — never a BSON requirement | `sdk/pages/pages_parameters.go` (`LayoutCallArgument.Widgets`), `mdl/executor/cmd_pages_builder_v3.go`, `sdk/mpr/writer_pages.go`, `mdl/backend/modelsdk/page_write.go`, `mdl/backend/mcp/page.go` | Make the field a list and place widgets directly. **Check the claim against Mendix's own output before believing a comment**: ours said the wrapper is what "mxcli (and Studio Pro) adds", but `Administration.Account_Overview` in a `mx create-project` app has *two* top-level widgets in one placeholder and zero wrappers — same reasoned-by-analogy error as #812/#295. Corroborating signal that a construct is wrong: DESCRIBE already unwrapped it as a "phantom CONTAINER" and the catalog skipped it as "transparent" — three places working around something that should not be created. **Keep those readers**: projects authored before the fix still contain wrappers. Repro `mdl-examples/bug-tests/760-no-placeholder-wrapper.mdl`. Issue #760 | +| `CREATE CONFIGURATION` (or any `ALTER SETTINGS`) reports success and `mx check` passes, but Studio Pro throws `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty.cs:25` when the changed unit is opened (e.g. from the version-control status grid). Silently, the same write also resets **HttpPortNumber/ServerPortNumber to 0** on every *existing* configuration | Three storage-name/enum defects in the settings write, all invisible to mxbuild (its deserializer tolerates unknown properties; Studio Pro resolves each stored property against the type's property list and throws when there is no match). (1) `createConfiguration` hardcoded `DatabaseType: "HSQLDB"` — the enum member is `Hsqldb`. (2) The gen `Configuration` binds the ports as `RuntimePortNumber`/`AdminPortNumber` (SDK names) while Studio Pro stores `HttpPortNumber`/`ServerPortNumber`, so the read returned 0 and the overlay wrote that 0 back. (3) Mendix renamed the runtime Java version property between 11.6 (`JavaVersion` = `"Java21"`) and 11.12 (`JavaMajorVersion` = `"21"`); mxcli wrote the 11.6 name unconditionally, leaving `JavaMajorVersion` stale and adding a property 11.12 does not define | `mdl/executor/cmd_settings.go` (`settingsDatabaseType`, `createConfiguration` defaults), `mdl/backend/modelsdk/settings_read.go` (`rawInt`, `javaVersionOf`), `mdl/settingsoverlay/settingsoverlay.go` (`JavaVersionKey`/`SetJavaVersion`, `newServerConfiguration`), `sdk/mpr/parser_settings.go` + `writer_settings.go` | Canonicalise enum-valued settings against `generated/metamodel` and reject the rest (executor **and** `mxcli check`, via a `settingsKind*` entry so the drift guard covers it). Read version-renamed properties off the stored document and write them back to the key they came from — **never invent a key the document does not already have** (the same reasoning removed the hardcoded `Tracing: nil` from the no-sibling fallback: 11.12 spells it `OpenTelemetry`). **Diagnose without Studio Pro**: dump the `Settings$ProjectSettings` unit before and after the command and diff key-by-key against the project `mx create-project` produced — the write must be purely additive. A "no matching element" *property* lookup means a key Mendix does not know; an enum member mismatch is a different exception. Repro: `create configuration 'X'` on an 11.12 project. Issue #759 | +| Any `ALTER SETTINGS` / `CREATE CONFIGURATION` corrupts a **private** constant override: the stored `Settings$PrivateValue` comes back carrying `"Value": ""`. Studio Pro then throws `System.InvalidOperationException: Sequence contains no matching element` at `MprProperty.cs:25` on open. `describe settings` separately renders the override as `value ''`, so replaying describe's own output converts it to a *shared* empty override | A constant override's value is either a `Settings$SharedValue` (carries `Value`, lives in the shared model) or a `Settings$PrivateValue` — a **marker type with no properties at all**, meaning the value is on the developer's workstation and deliberately out of version control. The overlay assumed SharedValue and wrote `cv.Value` (always `""` for a private override) into whichever node it found; the read type-asserted to `*SharedValue`, failed, and returned `""` with no way to distinguish private from empty | `mdl/settingsoverlay/settingsoverlay.go` (`constantValue`, `PrivateValueType`), `mdl/backend/modelsdk/settings_read.go` (`isPrivateConstantValue`), `sdk/mpr/parser_settings.go` (`parseConstantValue`), `mdl/executor/cmd_settings.go` (`describeSettings`, `alterSettingsConstant`) | Carry the distinction in the model (`model.ConstantValue.IsPrivate`) and **preserve, never author**: leave a PrivateValue node byte-identical, have `describe` emit a comment instead of a re-executable statement, and refuse an `alter settings constant` that would flip private→shared (drop is still allowed — it discards the whole override, which is what was asked). **Generalisable**: a polymorphic child whose variants differ in *arity* (one carries a value, one is a bare marker) cannot be overlaid by field assignment — branch on `$Type` first. Blast radius is wider than it looks: configurations are shared in version control, so one developer's unrelated edit corrupts every developer's private overrides and pushes the result. Found from a user describing their workflow, not from a filed issue | --- diff --git a/.claude/skills/mendix/create-page.md b/.claude/skills/mendix/create-page.md index 302fe8d92..8a5a60c1c 100644 --- a/.claude/skills/mendix/create-page.md +++ b/.claude/skills/mendix/create-page.md @@ -590,6 +590,19 @@ dataview dv (datasource: $Customer, LabelWidth: 4) -- explicit, 0 `LabelWidth: 0` ⇔ `FormOrientation: Vertical`. If both are given, `LabelWidth` wins. +**Footer (`showFooter`):** a `footer { … }` block turns the footer on by itself, so +the property is only needed when the two would disagree: + +```sql +dataview dv (datasource: $Customer, showFooter: true) -- empty footer, shown +dataview dv (datasource: $Customer, showFooter: false) { -- widgets declared, hidden + footer f { dynamictext t (content: 'hidden') } +} +``` + +An explicit `showFooter` wins over the block in both directions, and hiding a footer +never discards its widgets. + ### GALLERY Widget Display items in card layout with selection and responsive columns: diff --git a/.claude/skills/mendix/project-settings.md b/.claude/skills/mendix/project-settings.md index c3461ce43..f6a10b2e8 100644 --- a/.claude/skills/mendix/project-settings.md +++ b/.claude/skills/mendix/project-settings.md @@ -78,6 +78,27 @@ alter settings constant 'MyModule.ApiKey' value 'abc123'; alter settings drop constant 'MyModule.ApiKey' in configuration 'Default'; ``` +#### Shared vs private values + +A constant override's value is either **shared** — stored in the model and therefore +in version control, where every developer gets it — or **private**, stored on the +developer's own workstation and deliberately kept out of the repository. Development +API tokens are the usual reason to make one private. + +MDL **preserves that choice but never changes it**. The two statements above operate +on shared values only: + +- `show constant values` reports a private override as `(private)` rather than a blank + cell — the value is not in the project, so mxcli cannot show it. +- `describe settings` reports a private override as a comment, not as a re-executable + `alter settings constant` line — replaying that line would publish into the shared + model a value the developer chose to keep local. +- `alter settings constant ... value ...` on a private override is **refused**, with a + pointer to change it in Studio Pro first. Setting a value would convert it to a + shared one and break the developer's local binding. +- `alter settings drop constant ...` **is** allowed: it removes the whole override, + private marker included, which is what was asked for. + ### Create / Drop Configurations ```sql @@ -86,7 +107,7 @@ create configuration 'Staging'; -- Create with properties create configuration 'Production' - DatabaseType = 'POSTGRESQL', + DatabaseType = 'PostgreSql', DatabaseUrl = 'prod-db:5432', HttpPortNumber = 8080; @@ -94,6 +115,11 @@ create configuration 'Production' drop configuration 'Staging'; ``` +`DatabaseType` must name a Mendix database type — `Db2`, `Hsqldb`, `MySql`, +`Oracle`, `PostgreSql`, `SapHana` or `SqlServer` — matched case-insensitively and +stored in that spelling. Any other value is rejected; a configuration stored with +one Mendix does not recognise cannot be opened in Studio Pro. + ### Language and Workflow Settings ```sql diff --git a/.gitattributes b/.gitattributes index 4d0d43aaa..9ef51b5e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -51,3 +51,16 @@ Dockerfile text eol=lf *.vsix binary *.cdx.json binary bun.lock binary + +# Append-only knowledge files use git's union merge driver. +# +# Every bug fix appends a row to the symptom table in fix-issue.md, so two +# concurrent fixes always collide on the same line — five resolution rounds in +# one week. Moving the insertion point from the top of the table to the bottom +# did not help: both sides still append to the same place, so the collision +# moved with it. +# +# "union" tells git to keep BOTH sides of a conflicting hunk instead of raising +# a conflict. That is exactly right for a file that is only ever appended to and +# is looked up by matching a symptom, so row order carries no meaning. +.claude/skills/fix-issue.md merge=union diff --git a/CLAUDE.md b/CLAUDE.md index 87de43888..ac29c771a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,6 +205,36 @@ The reserved-word lists live in `mdl/executor/cmd_enumerations.go` (`mendixReser A microflow wired as the project's **after-startup** microflow must return `Boolean` — Mendix build fails with **CE0142** on a void (no-return) microflow. A common trip-up: a seed/demo-data microflow wired to after-startup will not build until it ends with a `return true` (Boolean). This is a Mendix platform rule, not an mxcli check. +### Overlay Writes: Never Invent a Key, Branch on `$Type` + +When a write overlays fields onto preserved BSON (`mdl/settingsoverlay`, and any +future storage that follows ADR-0005 guard-don't-drop), two rules are load-bearing. +Breaking either produces a document `mx check` accepts and **Studio Pro cannot +open**: it resolves every stored property against the type's property list and +throws `System.InvalidOperationException: Sequence contains no matching element` +at `MprProperty.cs`. mxbuild's deserializer tolerates unknown properties, so the +build is not a safety net here. + +1. **Write only keys the document already carries.** Property names are + version-specific — Mendix renamed `JavaVersion` (`"Java21"`) to + `JavaMajorVersion` (`"21"`) and `Tracing` to `OpenTelemetry` between 11.6 and + 11.12. Read the key off the stored document and write back to that same key; + when neither is present, write neither (an absent optional property is filled + in on load). See `settingsoverlay.JavaVersionKey` (#759). +2. **A polymorphic child must be dispatched on `$Type` before any field + assignment.** Variants can differ in *arity*, not just field values: + `Settings$SharedValue` carries a `Value`, while `Settings$PrivateValue` is a + bare marker with no properties at all (the value lives on the developer's + workstation). Assigning `Value` to whichever node is there corrupts the marker. + +The same reasoning bans authoring what the model does not own: mxcli preserves a +constant override's shared/private choice and refuses statements that would flip +it, rather than silently converting one to the other. + +Enum-valued properties are the sibling trap: validate against +`generated/metamodel` (e.g. `SettingsDatabaseType` is `Hsqldb`, never `HSQLDB`) +rather than passing a user string through. + ### Association Parent/Child Pointer Semantics (Counter-Intuitive) **CRITICAL**: Mendix BSON uses inverted naming for association pointers: @@ -285,7 +315,7 @@ When reviewing pull requests or validating work before commit, verify these item ### Bug fixes - [ ] **Fix-issue skill consulted** — read `.claude/skills/fix-issue.md` before diagnosing; match symptom to table before opening files -- [ ] **Symptom table updated** — new symptom/layer/file mapping added to `.claude/skills/fix-issue.md` if not already covered. **Append at the END of the table**: every fix touches this file, so inserting at the top means two concurrent fixes conflict at the same line by construction (five resolution rounds in one week). The table is looked up by matching a symptom, not read in order, so position carries no meaning +- [ ] **Symptom table updated** — new symptom/layer/file mapping added to `.claude/skills/fix-issue.md` if not already covered. Append at the END of the table — for readable diffs, not for merging: conflicts are handled by the `merge=union` driver in `.gitattributes`, which keeps both sides when two fixes append at once. (Insert position alone never fixed this; moving rows from the top to the bottom just moved the collision.) The table is looked up by matching a symptom, not read in order, so position carries no meaning - [ ] **Test written first** — failing test exists before implementation (parser test in `sdk/mpr/`, backend mutation test in `mdl/backend/mpr/`, executor handler test in `mdl/executor/` using `MockBackend`) - [ ] **Verified at the layer the symptom lives in** — a test proves something about the layer it exercises and nothing more. Parser/grammar → unit test. BSON we write → unit test on the encoded document. Files on disk after `mx` runs → integration test (`-tags integration`). **The rendered app's behaviour or appearance → `.claude/skills/verify-in-runtime.md`** (boot with `run --local`, assert in Playwright). A page can serialize to valid-looking BSON, pass `mx check`, build cleanly, and still render wrong — that was #812. - [ ] **Fix proven to be the cause** — revert the fix (or stub the guard) and confirm the test fails with the reported symptom. A test that only passes against fixed code has not been shown to detect anything; two bugs this week had a green suite while live (#812 a clobbered `RegisterTypeDefaults`, #808 an integration test that had only ever skipped) @@ -526,7 +556,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati - VS Code extension (`vscode-mdl`) with context menu commands (Run/Check/Selection) - Docker build integration (`mxcli docker build`) with PAD patching (Phase 1) - Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` -- External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain ` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends`), and a sortable availability overview at `hub./`. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4) +- External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain ` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends|sessions`), and an availability overview at `hub./` **grouped by Claude Code session** (`/api/sessions`): each session lists the endpoints it exposed and links back to its `claude.ai/code` conversation. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected); `--hub-session` groups a session's endpoints (auto-detected from `CLAUDE_CODE_REMOTE_SESSION_ID`). Past sessions are retained: a durable per-session endpoint history (`--sessions-file`, default `~/.mxcli/hub-sessions.json`) survives restarts and reaping, and is pruned after `--session-retention` (default 30d) — so the overview shows offline sessions too (`SessionLog` in `cmd/mxcli/tunnelhub/sessions.go`). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4) - Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub): **viewer plane** — GitHub OAuth web flow + HMAC-signed SSO session cookie (`Domain=.`), owner-checked previews (`--require-auth` default on → 302 to login / 403 non-owner; soft mode filters the listing only), `/api/backends` filtered to the viewer (unauthenticated → 401), admin "signed in as" via `/api/whoami`. **Registration plane** — durable, hashed hub API keys (`--keys-file`, default `~/.mxcli/hub-keys.json`, survive restarts) presented as `X-Hub-Key` → stamps `Backend.Owner`; shared `X-Hub-Secret` still works as an owner-less fallback. **Key issuance** — the hub's `/cli` browser page mints a key from the session cookie (no PAT; the device flow was removed as Claude Code containers block GitHub's device endpoints), rotate-by-default + count + revoke-all; `mxcli auth hub login --token ` is the headless path; `run --hub` reads `MXCLI_HUB_KEY` (env → `~/.mxcli/auth.json`) and degrades to local-only if registration fails. Append-only JSONL audit trail (`--audit-log`, no secrets). Packages: `cmd/mxcli/tunnelhub/` (+`audit/`), `cmd/mxcli/hubauth/`. See `docs/11-proposals/PROPOSAL_hub_authentication.md` - Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`): `--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`. The console exporter omits timestamps/parent span IDs (no flame charts), so `--trace-otlp ` (implies `--trace`) switches to the OTLP exporter (protocol `http/protobuf`) pointed at a collector; user-set `OTEL_*` env still takes precedence. See `.claude/skills/mendix/run-local.md` - OQL query execution against running runtime (`mxcli oql`) diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 7fd19364c..c2df3b6d7 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -81,6 +81,7 @@ Examples: hubSolution, _ := cmd.Flags().GetString("hub-solution") hubBranch, _ := cmd.Flags().GetString("hub-branch") hubWorktree, _ := cmd.Flags().GetString("hub-worktree") + hubSession, _ := cmd.Flags().GetString("hub-session") // --hub is a cross-cutting ingress and implies the local serving path (the // only serving mode wired today; a future PAD path will accept --hub too). hubKey := "" @@ -144,6 +145,7 @@ Examples: HubSolution: hubSolution, HubBranch: hubBranch, HubWorktree: hubWorktree, + HubSession: hubSession, AppPort: appPort, AdminPort: adminPort, ServePort: servePort, @@ -189,6 +191,7 @@ func init() { runCmd.Flags().String("hub-solution", "", "Solution name to group this app under in the hub overview (multi-app solutions)") runCmd.Flags().String("hub-branch", "", "Branch for the hub subdomain + overview (default: the git branch)") runCmd.Flags().String("hub-worktree", "", "Worktree label to distinguish multiple worktrees of one branch") + runCmd.Flags().String("hub-session", "", "Session id to group this preview under in the hub overview (default: CLAUDE_CODE_REMOTE_SESSION_ID / MXCLI_HUB_SESSION)") runCmd.Flags().Bool("watch", false, "Rebuild and hot-apply on every project change") runCmd.Flags().Bool("ensure-db", false, "Provision the local Postgres + app database if missing (fresh-session bootstrap)") runCmd.Flags().Bool("setup", false, "Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook") diff --git a/cmd/mxcli/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index b89ad72c1..6f2ca9f9c 100644 --- a/cmd/mxcli/cmd_tunnelhub.go +++ b/cmd/mxcli/cmd_tunnelhub.go @@ -72,6 +72,8 @@ Then, in each app's environment: requireAuth, _ := cmd.Flags().GetBool("require-auth") auditLog, _ := cmd.Flags().GetString("audit-log") keysFile, _ := cmd.Flags().GetString("keys-file") + sessionsFile, _ := cmd.Flags().GetString("sessions-file") + sessionRetention, _ := cmd.Flags().GetDuration("session-retention") if domain == "" { fmt.Fprintln(os.Stderr, "Error: --domain is required (the wildcard base, e.g. example.com)") @@ -103,7 +105,19 @@ Then, in each app's environment: auth := buildHubAuth(hubHost, domain, cookieDomain, ghClientID, ghClientSecret, sessionSecret, requireAuth, auditSink) - reg := tunnelhub.NewRegistry(tunnelhub.RegistryOptions{Domain: domain}) + // Persist the per-session endpoint history by default so the overview shows + // past sessions across restarts (30-day window unless overridden). + if sessionsFile == "" { + home, _ := os.UserHomeDir() + sessionsFile = filepath.Join(home, ".mxcli", "hub-sessions.json") + } + sessions, err := tunnelhub.NewSessionLogFile(sessionsFile, sessionRetention) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: opening --sessions-file %q: %v\n", sessionsFile, err) + os.Exit(1) + } + + reg := tunnelhub.NewRegistry(tunnelhub.RegistryOptions{Domain: domain, Sessions: sessions}) srv, err := tunnelhub.NewServer(tunnelhub.ServerOptions{ Domain: domain, HubHost: hubHost, @@ -193,5 +207,7 @@ func init() { tunnelHubCmd.Flags().Bool("require-auth", true, "Enforce the owner check on previews (deny non-owners); --require-auth=false leaves owned previews open but still filters the listing") tunnelHubCmd.Flags().String("audit-log", "", "Append-only JSONL audit trail path (\"stdout\" for stdout; empty = off)") tunnelHubCmd.Flags().String("keys-file", "", "Durable hub API-key store path (default ~/.mxcli/hub-keys.json when auth is on); keys survive restarts so clients keep their MXCLI_HUB_KEY") + tunnelHubCmd.Flags().String("sessions-file", "", "Durable per-session endpoint history path (default ~/.mxcli/hub-sessions.json); lets the overview show past sessions across restarts") + tunnelHubCmd.Flags().Duration("session-retention", tunnelhub.DefaultSessionRetention, "How long an offline session/endpoint stays in the overview before it is pruned") rootCmd.AddCommand(tunnelHubCmd) } diff --git a/cmd/mxcli/docker/build_integration_test.go b/cmd/mxcli/docker/build_integration_test.go index 3eb8b28d7..84be562d6 100644 --- a/cmd/mxcli/docker/build_integration_test.go +++ b/cmd/mxcli/docker/build_integration_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/mendixlabs/mxcli/sdk/mpr" + "github.com/mendixlabs/mxcli/sdk/mpr/version" ) // TestBuild_PreservesMPRv2StorageFormat is the end-to-end guard for @@ -55,6 +56,20 @@ func TestBuild_PreservesMPRv2StorageFormat(t *testing.T) { t.Skipf("scaffolded project is %v, not MPRv2 — nothing to protect", v) } + // Precondition: Build only supports Mendix >= 11.6.1 (portable app distribution); + // below that it refuses before reaching the update-widgets step this test is about. + // The nightly matrix includes 10.24, which is MPRv2 — so the format check above + // passes and Build then fails its own version guard, which is a property of the + // matrix row rather than a regression. + // + // This is a genuine capability gate, not a masked failure: there is no PAD build to + // protect on 10.x. The Check counterpart has no version guard and does run there, + // so MPRv2 preservation is still covered on every matrix row. + if pv := mprProductVersion(t, mprPath); !pv.IsAtLeastFull(11, 6, 1) { + t.Skipf("Build (portable app distribution) requires Mendix >= 11.6.1; scaffolded project is %s — "+ + "TestCheck_PreservesMPRv2StorageFormat covers this version", pv.ProductVersion) + } + var stdout bytes.Buffer if err := Build(BuildOptions{ ProjectPath: mprPath, @@ -73,3 +88,14 @@ func TestBuild_PreservesMPRv2StorageFormat(t *testing.T) { t.Errorf("mprcontents/ missing after Build, storage format was not preserved: %v", err) } } + +// mprProductVersion opens the .mpr and returns its Mendix product version. +func mprProductVersion(t *testing.T, mprPath string) *version.ProjectVersion { + t.Helper() + reader, err := mpr.Open(mprPath) + if err != nil { + t.Fatalf("mpr.Open(%s): %v", mprPath, err) + } + defer reader.Close() + return reader.ProjectVersion() +} diff --git a/cmd/mxcli/docker/hubclient.go b/cmd/mxcli/docker/hubclient.go index 9a8ece717..825625714 100644 --- a/cmd/mxcli/docker/hubclient.go +++ b/cmd/mxcli/docker/hubclient.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "os" "os/exec" "path/filepath" "strings" @@ -23,6 +24,7 @@ type HubMeta struct { Solution string // optional grouping for multi-app solutions Branch string // default: the project's git branch Worktree string // optional; distinguishes worktrees of one branch + Session string // Claude Code session id; groups a session's endpoints in the hub overview } // HubRegistration is the result of registering with a hub: everything the client @@ -73,6 +75,7 @@ func RegisterWithHub(hubURL, secret, key string, meta HubMeta, appPort int) (*Hu "solution": meta.Solution, "branch": meta.Branch, "worktree": meta.Worktree, + "session": meta.Session, "appPort": appPort, }) req, err := http.NewRequest(http.MethodPost, strings.TrimRight(hubURL, "/")+"/api/register", bytes.NewReader(body)) @@ -239,9 +242,26 @@ func DetectHubMeta(projectPath string, override HubMeta) HubMeta { if m.Branch == "" { m.Branch = gitBranch(filepath.Dir(projectPath)) } + if m.Session == "" { + m.Session = detectSessionID() + } return m } +// detectSessionID resolves the Claude Code session id used to group a session's +// endpoints in the hub overview. Preference: an explicit MXCLI_HUB_SESSION, then +// the remote (web) session id — which the hub can link back to the claude.ai +// conversation — then the per-run session id. Empty when none are set (e.g. a +// plain terminal), which groups the preview under "(no session)". +func detectSessionID() string { + for _, k := range []string{"MXCLI_HUB_SESSION", "CLAUDE_CODE_REMOTE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"} { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + } + return "" +} + // gitBranch returns the current branch of the repo containing dir, or "". func gitBranch(dir string) string { cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") diff --git a/cmd/mxcli/docker/hubmeta_session_test.go b/cmd/mxcli/docker/hubmeta_session_test.go new file mode 100644 index 000000000..476e96a21 --- /dev/null +++ b/cmd/mxcli/docker/hubmeta_session_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import "testing" + +func TestDetectSessionID_Precedence(t *testing.T) { + // Clear all sources; each subtest sets what it needs (t.Setenv restores after). + clear := func(t *testing.T) { + for _, k := range []string{"MXCLI_HUB_SESSION", "CLAUDE_CODE_REMOTE_SESSION_ID", "CLAUDE_CODE_SESSION_ID"} { + t.Setenv(k, "") + } + } + + t.Run("explicit override wins", func(t *testing.T) { + clear(t) + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "cse_remote") + t.Setenv("MXCLI_HUB_SESSION", "override") + if got := detectSessionID(); got != "override" { + t.Errorf("got %q, want override", got) + } + }) + t.Run("remote session id preferred over per-run", func(t *testing.T) { + clear(t) + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "cse_remote") + t.Setenv("CLAUDE_CODE_SESSION_ID", "uuid") + if got := detectSessionID(); got != "cse_remote" { + t.Errorf("got %q, want cse_remote", got) + } + }) + t.Run("falls back to per-run id", func(t *testing.T) { + clear(t) + t.Setenv("CLAUDE_CODE_SESSION_ID", "uuid") + if got := detectSessionID(); got != "uuid" { + t.Errorf("got %q, want uuid", got) + } + }) + t.Run("empty when none set", func(t *testing.T) { + clear(t) + if got := detectSessionID(); got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + +func TestDetectHubMeta_FillsSession(t *testing.T) { + t.Setenv("MXCLI_HUB_SESSION", "") + t.Setenv("CLAUDE_CODE_SESSION_ID", "") + t.Setenv("CLAUDE_CODE_REMOTE_SESSION_ID", "cse_web") + + m := DetectHubMeta("/tmp/App.mpr", HubMeta{}) + if m.Session != "cse_web" { + t.Errorf("Session = %q, want cse_web", m.Session) + } + + // An explicit override on the meta is not clobbered by the env. + m2 := DetectHubMeta("/tmp/App.mpr", HubMeta{Session: "explicit"}) + if m2.Session != "explicit" { + t.Errorf("Session = %q, want explicit (override preserved)", m2.Session) + } +} diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 88a1b4b1a..1353a1658 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -63,6 +63,7 @@ type LocalRunOptions struct { HubSolution string // grouping for multi-app solutions HubBranch string // override the auto-detected git branch HubWorktree string // distinguish worktrees of one branch + HubSession string // override the auto-detected Claude Code session id // Watch keeps running, rebuilding+applying on every project change. Watch bool // EnsureDB provisions the local Postgres + app database if missing (otherwise @@ -539,7 +540,7 @@ func RunLocal(opts LocalRunOptions) error { if opts.Hub != "" { meta := DetectHubMeta(opts.ProjectPath, HubMeta{ Prefix: opts.HubPrefix, Project: opts.HubProject, Solution: opts.HubSolution, - Branch: opts.HubBranch, Worktree: opts.HubWorktree, + Branch: opts.HubBranch, Worktree: opts.HubWorktree, Session: opts.HubSession, }) fmt.Fprintf(w, "Registering with hub %s...\n", opts.Hub) hubReg, err = RegisterWithHub(opts.Hub, opts.HubSecret, opts.HubKey, meta, opts.AppPort) diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index 9050d8069..e3e0ad134 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -172,7 +172,18 @@ func init() { "show constants", "constant values", "modify constant", "string constant", "integer constant", "boolean constant", }, - Syntax: "CREATE CONSTANT Module.Name\n TYPE String|Integer|Long|Decimal|Boolean|DateTime\n DEFAULT value\n [COMMENT 'description'];\n\nCREATE OR MODIFY CONSTANT Module.Name\n TYPE DataType DEFAULT value [COMMENT 'text'];\n\nSHOW CONSTANTS;\nSHOW CONSTANTS IN ;\nSHOW CONSTANT VALUES;\nDESCRIBE CONSTANT Module.Name;\nDROP CONSTANT Module.Name;\n\nRemove override:\n ALTER SETTINGS DROP CONSTANT 'Module.Name' IN CONFIGURATION 'cfg';", + Syntax: "CREATE CONSTANT Module.Name\n TYPE String|Integer|Long|Decimal|Boolean|DateTime\n DEFAULT value\n [COMMENT 'description'];\n\nCREATE OR MODIFY CONSTANT Module.Name\n TYPE DataType DEFAULT value [COMMENT 'text'];\n\nSHOW CONSTANTS;\nSHOW CONSTANTS IN ;\nSHOW CONSTANT VALUES;\nDESCRIBE CONSTANT Module.Name;\nDROP CONSTANT Module.Name;\n\nRemove override:\n ALTER SETTINGS DROP CONSTANT 'Module.Name' IN CONFIGURATION 'cfg';\n\n" + + "Shared vs private values:\n" + + " A per-configuration override holds either a SHARED value (stored in the\n" + + " model, so in version control — every developer gets it) or a PRIVATE one\n" + + " (stored on the developer's own workstation, deliberately out of the repo;\n" + + " the usual choice for development API tokens).\n\n" + + " MDL preserves that choice but never changes it. ALTER SETTINGS CONSTANT\n" + + " applies to shared values only — on a private override it is refused, since\n" + + " setting a value would publish a deliberately-local one into version control.\n" + + " SHOW CONSTANT VALUES reports it as (private); DESCRIBE SETTINGS reports it\n" + + " as a comment, not a re-executable statement. DROP CONSTANT still works.\n" + + " Change a constant to a shared value in Studio Pro.", Example: "CREATE CONSTANT MyModule.ApiBaseUrl\n TYPE String\n DEFAULT 'https://api.example.com/v1';\n\nCREATE CONSTANT MyModule.MaxRetries\n TYPE Integer DEFAULT 3\n COMMENT 'Maximum number of API retry attempts';\n\nCREATE CONSTANT MyModule.EnableDebug\n TYPE Boolean DEFAULT false;\n\nCREATE OR MODIFY CONSTANT MyModule.ApiBaseUrl\n TYPE String\n DEFAULT 'https://api.staging.example.com/v2';", SeeAlso: []string{"domain-model.constant"}, }) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 72ed6578a..131a885de 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -165,8 +165,12 @@ ALTER SETTINGS CONFIGURATION 'Default' ALTER SETTINGS CONSTANT 'BusinessEvents.ServerUrl' VALUE 'kafka:9092' IN CONFIGURATION 'Default'; CREATE CONFIGURATION 'Production' - DatabaseType = 'POSTGRESQL', - HttpPortNumber = 8080;`, + DatabaseType = 'PostgreSql', + HttpPortNumber = 8080; + +-- DatabaseType must be a Mendix database type: +-- Db2, Hsqldb, MySql, Oracle, PostgreSql, SapHana, SqlServer +-- (matched case-insensitively and stored in the spelling above).`, SeeAlso: []string{"settings.show"}, }) diff --git a/cmd/mxcli/tunnelhub/admin.go b/cmd/mxcli/tunnelhub/admin.go index 5e6504aa3..0f945472a 100644 --- a/cmd/mxcli/tunnelhub/admin.go +++ b/cmd/mxcli/tunnelhub/admin.go @@ -43,11 +43,21 @@ const adminHTML = ` tbody tr:nth-child(even){ background:var(--row); } td.url a { color:var(--accent); text-decoration:none; } td.url a:hover { text-decoration:underline; } - .dot { display:inline-block; width:.62rem; height:.62rem; border-radius:50%; margin-right:.4rem; vertical-align:-1px; } - .available .dot { background:#22c55e; } .stale .dot { background:#f59e0b; } - .available { color:inherit; } .stale { color:var(--mut); } + .dot { display:inline-block; width:.62rem; height:.62rem; border-radius:50%; margin-right:.4rem; vertical-align:-1px; background:var(--mut); } + .dot.available { background:#22c55e; } .dot.stale { background:#f59e0b; } .dot.offline { background:#9ca3af; } + tr.stale, tr.offline { color:var(--mut); } .sol { color:var(--mut); font-size:.82rem; } .empty { color:var(--mut); padding:2rem 0; } + .ses { border:1px solid var(--line); border-radius:.6rem; margin-bottom:1rem; overflow:hidden; } + .ses.offline { opacity:.72; } + .sh { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; padding:.6rem .8rem; background:var(--row); border-bottom:1px solid var(--line); } + .sh .sid { font-weight:600; font-family:ui-monospace,monospace; font-size:.9rem; } + .sh .sid a { color:var(--accent); text-decoration:none; } .sh .sid a:hover { text-decoration:underline; } + .sh .own { color:var(--mut); font-size:.85rem; } .sh .own::before { content:"@"; } + .sh .cnt { color:var(--mut); font-size:.82rem; } + .sh .ls { color:var(--mut); font-size:.82rem; margin-left:auto; } + .ses table { min-width:0; } + .ses td, .ses th { white-space:nowrap; } code { font-family:ui-monospace,monospace; } .who { color:var(--mut); font-size:.85rem; } .who b { color:var(--fg); font-weight:600; } @@ -64,25 +74,11 @@ const adminHTML = `
- - - - - - - - - - - - - -
StatusSolutionProjectBranchURLRegisteredLast seenLast usedUptime
+
diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 143135cac..09bd6515b 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -89,6 +89,7 @@ func (a *API) Mount(mux *http.ServeMux) { mux.HandleFunc("/api/status", a.handleStatus) mux.HandleFunc("/api/deregister", a.handleDeregister) mux.HandleFunc("/api/backends", a.handleBackends) + mux.HandleFunc("/api/sessions", a.handleSessions) mux.HandleFunc("/api/keys", a.handleKeys) mux.HandleFunc("/api/auth-config", a.handleAuthConfig) mux.HandleFunc("/api/whoami", a.handleWhoami) @@ -415,6 +416,23 @@ func (a *API) handleBackends(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, a.opts.Registry.List(sort, "")) } +// handleSessions (GET /api/sessions) returns the registered endpoints grouped by +// Claude Code session, including offline ones from history. Viewer-scoped exactly +// like /api/backends: with auth on it requires a session and filters to that +// owner; open mode returns all. +func (a *API) handleSessions(w http.ResponseWriter, r *http.Request) { + if a.opts.Auth.enabled() { + login := a.opts.Auth.sessionLogin(r) + if login == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + writeJSON(w, http.StatusOK, a.opts.Registry.Sessions(login)) + return + } + writeJSON(w, http.StatusOK, a.opts.Registry.Sessions("")) +} + func bearerToken(r *http.Request) string { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) diff --git a/cmd/mxcli/tunnelhub/registry.go b/cmd/mxcli/tunnelhub/registry.go index ba4ded4fb..28bf83e27 100644 --- a/cmd/mxcli/tunnelhub/registry.go +++ b/cmd/mxcli/tunnelhub/registry.go @@ -33,6 +33,7 @@ type Backend struct { Branch string `json:"branch"` // git branch Worktree string `json:"worktree"` // optional, distinguishes worktrees of one branch Owner string `json:"owner"` // GitHub login that registered it ("" = anonymous / self-hosted / auth off) + Session string `json:"session"` // Claude Code session id that registered it ("" = none / older client) Subdomain string `json:"subdomain"` ReversePort int `json:"reversePort"` AppPort int `json:"appPort"` @@ -65,6 +66,7 @@ type RegisterRequest struct { Solution string `json:"solution"` Branch string `json:"branch"` Worktree string `json:"worktree"` + Session string `json:"session"` // Claude Code session id (client-supplied; groups a session's endpoints) AppPort int `json:"appPort"` // Owner is set server-side from the X-Hub-Key → login lookup, never trusted // from the client body (json:"-" keeps it off the wire). @@ -86,6 +88,7 @@ type Registry struct { staleFor time.Duration expireFor time.Duration now func() time.Time + sessions *SessionLog // durable per-session endpoint history (nil = disabled) } // RegistryOptions configures a Registry. Zero values get sensible defaults. @@ -96,6 +99,7 @@ type RegistryOptions struct { StaleFor time.Duration // no heartbeat within this -> Stale (default 45s) ExpireFor time.Duration // no heartbeat within this -> removed (default 10m) Now func() time.Time + Sessions *SessionLog // durable per-session endpoint history (nil = disabled) } // NewRegistry creates an empty registry. @@ -126,6 +130,7 @@ func NewRegistry(o RegistryOptions) *Registry { staleFor: o.StaleFor, expireFor: o.ExpireFor, now: o.Now, + sessions: o.Sessions, } } @@ -146,11 +151,14 @@ func (r *Registry) Register(req RegisterRequest) (*Backend, error) { Branch: strings.TrimSpace(req.Branch), Worktree: strings.TrimSpace(req.Worktree), Owner: strings.TrimSpace(req.Owner), + Session: strings.TrimSpace(req.Session), AppPort: req.AppPort, } if existing, ok := r.byIdentity[b.identity()]; ok { existing.LastSeenAt = now existing.AppPort = req.AppPort + existing.Session = b.Session // a reconnect may carry a newer session id + r.recordSessionLocked(existing) return existing, nil } @@ -169,9 +177,40 @@ func (r *Registry) Register(req RegisterRequest) (*Backend, error) { r.bySubdomain[b.Subdomain] = b r.byIdentity[b.identity()] = b r.usedPorts[port] = true + r.recordSessionLocked(b) return b, nil } +// recordSessionLocked mirrors a backend's current state into the durable session +// log so it survives reaping. No-op when the session log is disabled. +func (r *Registry) recordSessionLocked(b *Backend) { + if r.sessions == nil { + return + } + r.sessions.Record(EndpointRecord{ + Session: b.Session, + Owner: b.Owner, + Prefix: b.Prefix, + Project: b.Project, + Solution: b.Solution, + Branch: b.Branch, + Worktree: b.Worktree, + Subdomain: b.Subdomain, + URL: r.urlForLocked(b), + RegisteredAt: b.RegisteredAt, + LastSeenAt: b.LastSeenAt, + }) +} + +// urlForLocked is the public URL of a backend (subdomain under the hub domain). +func (r *Registry) urlForLocked(b *Backend) string { + host := b.Subdomain + if r.domain != "" { + host = b.Subdomain + "." + r.domain + } + return "https://" + host +} + // Heartbeat refreshes a backend's liveness by token. func (r *Registry) Heartbeat(id string) bool { r.mu.Lock() @@ -237,6 +276,103 @@ func (r *Registry) List(sortKey, viewerLogin string) []BackendView { return out } +// Sessions returns the registered endpoints grouped by Claude Code session, +// merging live backends (available/stale) with the durable history of offline +// ones. When viewerLogin is non-empty, only that owner's sessions are returned +// (auth off / self-hosted passes "" for all). Sessions are sorted most-recently +// -seen first; endpoints within a session likewise. +func (r *Registry) Sessions(viewerLogin string) []SessionView { + r.mu.Lock() + r.reapLocked() + + // Live endpoints first — keyed by identity so a history record for the same + // slot is treated as the same (live) endpoint, not duplicated as offline. + type epKey = string + live := map[epKey]EndpointView{} + meta := map[epKey]struct{ session, owner string }{} + for _, b := range r.byID { + if viewerLogin != "" && b.Owner != viewerLogin { + continue + } + v := r.viewLocked(b) + k := b.identity() + live[k] = EndpointView{ + Subdomain: b.Subdomain, URL: v.URL, Prefix: b.Prefix, Project: b.Project, + Solution: b.Solution, Branch: b.Branch, Worktree: b.Worktree, + State: string(v.Availability), RegisteredAt: b.RegisteredAt, + LastSeenAt: b.LastSeenAt, LastUsedAt: b.LastUsedAt, UptimeSec: v.UptimeSec, + } + meta[k] = struct{ session, owner string }{b.Session, b.Owner} + } + history := r.sessions.Snapshot() // nil-safe + r.mu.Unlock() + + // Group by session. Live endpoints override any offline record for the same slot. + type grp struct { + owner string + eps map[epKey]EndpointView + } + groups := map[string]*grp{} + ensure := func(session, owner string) *grp { + g, ok := groups[session] + if !ok { + g = &grp{owner: owner, eps: map[epKey]EndpointView{}} + groups[session] = g + } + if g.owner == "" { + g.owner = owner + } + return g + } + for k, ev := range live { + m := meta[k] + ensure(m.session, m.owner).eps[k] = ev + } + for _, rec := range history { + if viewerLogin != "" && rec.Owner != viewerLogin { + continue + } + g := ensure(rec.Session, rec.Owner) + k := strings.Join([]string{rec.Owner, rec.Prefix, rec.Solution, rec.Project, rec.Branch, rec.Worktree}, "\x00") + if _, isLive := g.eps[k]; isLive { + continue // live entry wins + } + g.eps[k] = EndpointView{ + Subdomain: rec.Subdomain, URL: rec.URL, Prefix: rec.Prefix, Project: rec.Project, + Solution: rec.Solution, Branch: rec.Branch, Worktree: rec.Worktree, + State: "offline", RegisteredAt: rec.RegisteredAt, LastSeenAt: rec.LastSeenAt, + } + } + + out := make([]SessionView, 0, len(groups)) + for session, g := range groups { + sv := SessionView{Session: session, SessionURL: sessionURL(session), Owner: g.owner} + for _, ev := range g.eps { + sv.Endpoints = append(sv.Endpoints, ev) + if ev.State != "offline" { + sv.Online = true + } + if sv.FirstSeen.IsZero() || (!ev.RegisteredAt.IsZero() && ev.RegisteredAt.Before(sv.FirstSeen)) { + sv.FirstSeen = ev.RegisteredAt + } + if ev.LastSeenAt.After(sv.LastSeen) { + sv.LastSeen = ev.LastSeenAt + } + } + sort.Slice(sv.Endpoints, func(i, j int) bool { + return sv.Endpoints[i].LastSeenAt.After(sv.Endpoints[j].LastSeenAt) + }) + out = append(out, sv) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Online != out[j].Online { + return out[i].Online // online sessions first + } + return out[i].LastSeen.After(out[j].LastSeen) + }) + return out +} + // viewLocked builds the derived view for a backend. func (r *Registry) viewLocked(b *Backend) BackendView { host := b.Subdomain @@ -266,6 +402,9 @@ func (r *Registry) reapLocked() { } func (r *Registry) removeLocked(b *Backend) { + // Stamp the final liveness into the session log before dropping the live + // entry, so the offline history shows an accurate last-seen. + r.recordSessionLocked(b) delete(r.byID, b.ID) delete(r.bySubdomain, b.Subdomain) delete(r.byIdentity, b.identity()) diff --git a/cmd/mxcli/tunnelhub/sessions.go b/cmd/mxcli/tunnelhub/sessions.go new file mode 100644 index 000000000..953f9fcd3 --- /dev/null +++ b/cmd/mxcli/tunnelhub/sessions.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +// A Claude Code session groups the preview endpoints it exposed. The client +// sends its session id (CLAUDE_CODE_REMOTE_SESSION_ID, e.g. "cse_01JX…") on +// registration; the hub groups backends by it and — because a live Backend is +// reaped ~10 min after the container goes idle — keeps a durable record here so +// past sessions stay visible in the overview. + +// EndpointRecord is a persisted record of one preview endpoint a session +// exposed. It outlives the live Backend (which is dropped on reap) so the +// overview can show sessions that have gone offline. +type EndpointRecord struct { + Session string `json:"session"` + Owner string `json:"owner"` + Prefix string `json:"prefix"` + Project string `json:"project"` + Solution string `json:"solution"` + Branch string `json:"branch"` + Worktree string `json:"worktree"` + Subdomain string `json:"subdomain"` + URL string `json:"url"` + RegisteredAt time.Time `json:"registeredAt"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +// key is the stable identity of an endpoint within the log: same session + owner +// + slot re-registers to the same record (so a reconnect updates rather than +// duplicates). It mirrors Backend.identity() with the session prepended. +func (e *EndpointRecord) key() string { + return strings.Join([]string{e.Session, e.Owner, e.Prefix, e.Solution, e.Project, e.Branch, e.Worktree}, "\x00") +} + +// SessionLog is the durable history of endpoints seen per session. Records are +// pruned once their last-seen is older than the retention window. All methods +// are safe for concurrent use. +type SessionLog struct { + mu sync.Mutex + byKey map[string]*EndpointRecord + path string // "" = in-memory only (no persistence) + retention time.Duration // records older than this (by LastSeenAt) are pruned + now func() time.Time +} + +// sessionsFile is the on-disk layout for the durable session log. +type sessionsFile struct { + Version int `json:"version"` + Endpoints []*EndpointRecord `json:"endpoints"` +} + +const sessionsFileVersion = 1 + +// DefaultSessionRetention is how long an offline endpoint stays in the overview. +const DefaultSessionRetention = 30 * 24 * time.Hour + +// NewSessionLog returns an in-memory session log (no persistence). Suitable for +// tests and open hubs that don't need history across restarts. +func NewSessionLog(retention time.Duration) *SessionLog { + if retention <= 0 { + retention = DefaultSessionRetention + } + return &SessionLog{byKey: map[string]*EndpointRecord{}, retention: retention, now: time.Now} +} + +// NewSessionLogFile returns a durable session log backed by path. An existing +// file is loaded and pruned; Record writes through (atomic, mode 0600). +func NewSessionLogFile(path string, retention time.Duration) (*SessionLog, error) { + s := NewSessionLog(retention) + s.path = path + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *SessionLog) clock() time.Time { + if s.now != nil { + return s.now() + } + return time.Now() +} + +// Record upserts an endpoint sighting: the earliest RegisteredAt and the latest +// LastSeenAt win, so a reconnect extends the same record. Mutable fields (owner, +// subdomain, url) are refreshed. Prunes and persists. +func (s *SessionLog) Record(e EndpointRecord) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + k := e.key() + if cur, ok := s.byKey[k]; ok { + if !e.RegisteredAt.IsZero() && (cur.RegisteredAt.IsZero() || e.RegisteredAt.Before(cur.RegisteredAt)) { + cur.RegisteredAt = e.RegisteredAt + } + if e.LastSeenAt.After(cur.LastSeenAt) { + cur.LastSeenAt = e.LastSeenAt + } + cur.Owner, cur.Subdomain, cur.URL = e.Owner, e.Subdomain, e.URL + } else { + cp := e + s.byKey[k] = &cp + } + s.pruneLocked() + _ = s.saveLocked() +} + +// Snapshot returns a pruned copy of all records. +func (s *SessionLog) Snapshot() []EndpointRecord { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + s.pruneLocked() + out := make([]EndpointRecord, 0, len(s.byKey)) + for _, r := range s.byKey { + out = append(out, *r) + } + sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) }) + return out +} + +// pruneLocked drops records whose LastSeenAt is older than the retention window. +func (s *SessionLog) pruneLocked() { + cutoff := s.clock().Add(-s.retention) + for k, r := range s.byKey { + if r.LastSeenAt.Before(cutoff) { + delete(s.byKey, k) + } + } +} + +func (s *SessionLog) load() error { + if s.path == "" { + return nil + } + info, err := os.Stat(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("session log: stat %s: %w", s.path, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("session log %s has too-open permissions %o (want 0600)", s.path, info.Mode().Perm()) + } + data, err := os.ReadFile(s.path) + if err != nil { + return fmt.Errorf("session log: read %s: %w", s.path, err) + } + if len(data) == 0 { + return nil + } + var sf sessionsFile + if err := json.Unmarshal(data, &sf); err != nil { + return fmt.Errorf("session log: parse %s: %w", s.path, err) + } + for _, r := range sf.Endpoints { + if r != nil { + s.byKey[r.key()] = r + } + } + s.pruneLocked() + return nil +} + +// saveLocked atomically writes the log to disk (temp + rename, mode 0600). The +// caller must hold s.mu. No-op for an in-memory log. +func (s *SessionLog) saveLocked() error { + if s.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + eps := make([]*EndpointRecord, 0, len(s.byKey)) + for _, r := range s.byKey { + eps = append(eps, r) + } + sort.Slice(eps, func(i, j int) bool { return eps[i].LastSeenAt.After(eps[j].LastSeenAt) }) + data, err := json.MarshalIndent(sessionsFile{Version: sessionsFileVersion, Endpoints: eps}, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".hub-sessions.*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, s.path) +} + +// EndpointView is one endpoint in a SessionView, live or historical. +type EndpointView struct { + Subdomain string `json:"subdomain"` + URL string `json:"url"` + Prefix string `json:"prefix"` + Project string `json:"project"` + Solution string `json:"solution"` + Branch string `json:"branch"` + Worktree string `json:"worktree"` + State string `json:"state"` // "available" | "stale" | "offline" + RegisteredAt time.Time `json:"registeredAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + LastUsedAt time.Time `json:"lastUsedAt"` + UptimeSec int64 `json:"uptimeSec"` +} + +// SessionView groups the endpoints a single Claude Code session exposed, live +// and historical. +type SessionView struct { + Session string `json:"session"` + SessionURL string `json:"sessionUrl"` // claude.ai link when derivable, else "" + Owner string `json:"owner"` + Online bool `json:"online"` // any endpoint currently available/stale + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Endpoints []EndpointView `json:"endpoints"` +} + +// sessionURL maps a Claude Code remote session id to its conversation URL. +// CLAUDE_CODE_REMOTE_SESSION_ID is "cse_" and the web URL is +// "https://claude.ai/code/session_". Other id shapes get no link. +func sessionURL(session string) string { + if id, ok := strings.CutPrefix(session, "cse_"); ok && id != "" { + return "https://claude.ai/code/session_" + id + } + return "" +} diff --git a/cmd/mxcli/tunnelhub/sessions_test.go b/cmd/mxcli/tunnelhub/sessions_test.go new file mode 100644 index 000000000..58d6bde90 --- /dev/null +++ b/cmd/mxcli/tunnelhub/sessions_test.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "path/filepath" + "testing" + "time" +) + +func TestSessionURL(t *testing.T) { + cases := map[string]string{ + "cse_01JX": "https://claude.ai/code/session_01JX", + "cse_": "", // no id after prefix + "abc123": "", // not a remote id + "": "", + } + for in, want := range cases { + if got := sessionURL(in); got != want { + t.Errorf("sessionURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSessionLog_PersistAndPrune(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "hub-sessions.json") + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + + log, err := NewSessionLogFile(path, 30*24*time.Hour) + if err != nil { + t.Fatalf("NewSessionLogFile: %v", err) + } + log.now = func() time.Time { return base } + log.Record(EndpointRecord{ + Session: "cse_A", Owner: "alice", Project: "App", Branch: "main", + Subdomain: "app", URL: "https://app.example.com", + RegisteredAt: base.Add(-2 * time.Hour), LastSeenAt: base.Add(-time.Hour), + }) + // A stale record that should be pruned on reload (older than retention). + log.Record(EndpointRecord{ + Session: "cse_OLD", Owner: "alice", Project: "Ancient", Branch: "main", + Subdomain: "ancient", LastSeenAt: base.Add(-40 * 24 * time.Hour), + }) + + // Reload from disk with "now" past the old record's retention window. + log2, err := NewSessionLogFile(path, 30*24*time.Hour) + if err != nil { + t.Fatalf("reload: %v", err) + } + log2.now = func() time.Time { return base } + snap := log2.Snapshot() + if len(snap) != 1 { + t.Fatalf("after reload+prune: %d records, want 1 (%+v)", len(snap), snap) + } + if snap[0].Session != "cse_A" || snap[0].Project != "App" { + t.Errorf("unexpected surviving record: %+v", snap[0]) + } +} + +func TestSessionLog_RecordUpsertKeepsEarliestRegistered(t *testing.T) { + log := NewSessionLog(30 * 24 * time.Hour) + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + log.now = func() time.Time { return base } + + e := EndpointRecord{Session: "s", Owner: "o", Project: "P", Branch: "main"} + e.RegisteredAt, e.LastSeenAt = base.Add(-3*time.Hour), base.Add(-3*time.Hour) + log.Record(e) + e.RegisteredAt, e.LastSeenAt = base.Add(-time.Hour), base // a reconnect + log.Record(e) + + snap := log.Snapshot() + if len(snap) != 1 { + t.Fatalf("want 1 merged record, got %d", len(snap)) + } + if !snap[0].RegisteredAt.Equal(base.Add(-3 * time.Hour)) { + t.Errorf("RegisteredAt = %v, want earliest -3h", snap[0].RegisteredAt) + } + if !snap[0].LastSeenAt.Equal(base) { + t.Errorf("LastSeenAt = %v, want latest (base)", snap[0].LastSeenAt) + } +} + +func TestRegistry_SessionsGroupsAndRetainsOffline(t *testing.T) { + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + now := base + reg := NewRegistry(RegistryOptions{ + Domain: "example.com", + StaleFor: 45 * time.Second, + ExpireFor: 10 * time.Minute, + Sessions: NewSessionLog(30 * 24 * time.Hour), + Now: func() time.Time { return now }, + }) + reg.sessions.now = func() time.Time { return now } + + // Session A exposes two endpoints; session B one. + reg.Register(RegisterRequest{Session: "cse_A", Owner: "alice", Project: "Web", Branch: "main"}) + reg.Register(RegisterRequest{Session: "cse_A", Owner: "alice", Project: "Api", Branch: "main"}) + reg.Register(RegisterRequest{Session: "cse_B", Owner: "alice", Project: "Web", Branch: "feature"}) + + sessions := reg.Sessions("") + if len(sessions) != 2 { + t.Fatalf("want 2 sessions, got %d", len(sessions)) + } + byID := map[string]SessionView{} + for _, s := range sessions { + byID[s.Session] = s + } + if a := byID["cse_A"]; len(a.Endpoints) != 2 || !a.Online || a.SessionURL != "https://claude.ai/code/session_A" { + t.Errorf("session A wrong: eps=%d online=%v url=%q", len(a.Endpoints), a.Online, a.SessionURL) + } + for _, e := range byID["cse_A"].Endpoints { + if e.State != "available" { + t.Errorf("A endpoint %s state=%q, want available", e.Project, e.State) + } + } + + // Advance past expiry so session B's endpoint is reaped → offline history. + now = base.Add(11 * time.Minute) + sessions = reg.Sessions("") + // A's endpoints are also stale now (no heartbeat) but still live; B is offline. + var b SessionView + var found bool + for _, s := range sessions { + if s.Session == "cse_B" { + b, found = s, true + } + } + if !found { + t.Fatal("session B dropped from history after reap; want it retained offline") + } + if b.Online { + t.Errorf("session B should be offline after reap") + } + if len(b.Endpoints) != 1 || b.Endpoints[0].State != "offline" { + t.Fatalf("session B endpoint should be offline: %+v", b.Endpoints) + } +} + +func TestRegistry_SessionsViewerScoped(t *testing.T) { + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + reg := NewRegistry(RegistryOptions{ + Domain: "example.com", Sessions: NewSessionLog(0), Now: func() time.Time { return now }, + }) + reg.Register(RegisterRequest{Session: "cse_A", Owner: "alice", Project: "Web", Branch: "main"}) + reg.Register(RegisterRequest{Session: "cse_B", Owner: "bob", Project: "Web", Branch: "main"}) + + if got := reg.Sessions("alice"); len(got) != 1 || got[0].Owner != "alice" { + t.Errorf("viewer alice should see only her session, got %+v", got) + } + if got := reg.Sessions(""); len(got) != 2 { + t.Errorf("open mode should see all sessions, got %d", len(got)) + } +} diff --git a/docs-site/src/reference/domain-model/create-constant.md b/docs-site/src/reference/domain-model/create-constant.md index 7ee808422..a3a6ef5fa 100644 --- a/docs-site/src/reference/domain-model/create-constant.md +++ b/docs-site/src/reference/domain-model/create-constant.md @@ -75,6 +75,28 @@ CREATE CONSTANT MyModule.ApiBaseUrl TYPE String DEFAULT 'https://api.example.com ALTER SETTINGS CONSTANT 'MyModule.ApiBaseUrl' VALUE 'https://staging.example.com' IN CONFIGURATION 'Staging'; ``` +### Shared and private values + +An override holds its value one of two ways: + +- **Shared** — stored in the model, so it travels with the project in version + control and every developer gets it. +- **Private** — stored on the developer's own workstation and deliberately kept + out of the repository. This is the answer for a development secret: the + constant and the override are shared, the value is not. + +MDL **preserves that choice but never changes it** — the shared/private decision +belongs to the constant, and configurations just respect it: + +| Statement | On a private override | +|-----------|----------------------| +| `ALTER SETTINGS CONSTANT … VALUE …` | **refused** — setting a value would convert it to shared and publish a deliberately-local value into version control | +| `ALTER SETTINGS DROP CONSTANT …` | allowed — removes the whole override, which is what was asked for | +| `SHOW CONSTANT VALUES` | reports `(private)` rather than a blank cell | +| `DESCRIBE SETTINGS` | emits a comment, not a re-executable statement | + +To make a private value shared (or the reverse), change it in Studio Pro. + ## See Also [CREATE ENTITY](create-entity.md), [CREATE ENUMERATION](create-enumeration.md) diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index f062df66c..493452f5a 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -76,6 +76,18 @@ ALTER SETTINGS CONFIGURATION 'production' DatabaseUrl = 'jdbc:postgresql://dbhos ALTER SETTINGS CONSTANT 'MyModule.ApiBaseUrl' VALUE 'https://api.staging.example.com' IN CONFIGURATION 'staging'; ``` +An override's value is either **shared** — stored in the model, and so in version +control — or **private**, stored on the developer's own workstation and kept out of +the repository (the usual choice for development API tokens). + +MDL preserves that choice but never changes it. `ALTER SETTINGS CONSTANT ... VALUE` +applies to shared values only; on a private override it is **refused**, because +setting a value would convert it to a shared one, publish a deliberately-local value +into version control, and break the developer's local binding. Change the constant to +a shared value in Studio Pro first, or drop the override. `DESCRIBE SETTINGS` reports +a private override as a comment rather than a re-executable statement, for the same +reason. + ### Set the default language ```sql @@ -104,11 +116,17 @@ ALTER SETTINGS DROP CONSTANT 'MyModule.ApiBaseUrl' IN CONFIGURATION 'staging'; ```sql CREATE CONFIGURATION 'Staging' - DatabaseType = 'POSTGRESQL', + DatabaseType = 'PostgreSql', DatabaseUrl = 'staging-db:5432', HttpPortNumber = 8080; ``` +`DatabaseType` must name a Mendix database type — `Db2`, `Hsqldb`, `MySql`, +`Oracle`, `PostgreSql`, `SapHana` or `SqlServer`. The value is matched +case-insensitively and stored in the spelling above; anything else is rejected by +`mxcli check` and by the executor. A configuration created without properties gets +Studio Pro's defaults: `Hsqldb`, runtime port 8080, admin port 8090. + ### Drop a configuration ```sql diff --git a/docs-site/src/reference/settings/show-settings.md b/docs-site/src/reference/settings/show-settings.md index b03eaa02b..dbc929ffa 100644 --- a/docs-site/src/reference/settings/show-settings.md +++ b/docs-site/src/reference/settings/show-settings.md @@ -18,7 +18,7 @@ The available settings categories are: |----------|----------| | `MODEL` | Application-level settings: AfterStartupMicroflow, BeforeShutdownMicroflow, HashAlgorithm, JavaVersion, etc. | | `CONFIGURATION` | Runtime configurations: DatabaseType, DatabaseUrl, HttpPortNumber, etc. Each named configuration is listed separately. | -| `CONSTANT` | Constant value overrides per configuration. Shows which constants have non-default values in each configuration. | +| `CONSTANT` | Constant value overrides per configuration. Shows which constants have non-default values in each configuration. An override whose value is private — stored on the developer's workstation rather than in the shared model — is reported as `(private)`; its value is not in the project and mxcli cannot show it. | | `LANGUAGE` | Localization settings: DefaultLanguageCode and available languages. | | `WORKFLOWS` | Workflow engine settings: UserEntity, DefaultTaskParallelism, etc. | diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 277b7418c..ffb280a4c 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -178,8 +178,14 @@ projects, solutions, branches, and worktrees — with a sortable overview at `--hub-project`/`--hub-branch`, and `--hub-worktree` separates worktrees of one branch. - **`--hub-prefix`** namespaces the hostname (org/solution/team/env) → `--`; **`--hub-solution`** groups a solution's apps in the overview. -- The overview shows availability — a reaped/idle container turns **stale** — and sorts by - last-used, registered, or project. Re-registering keeps a **stable URL**. +- The overview **groups previews by Claude Code session** (agent): each session lists the + endpoints it exposed, links back to its `claude.ai/code` conversation, and shows its + availability — a reaped/idle container turns **stale**, then **offline**. `run --hub` + auto-detects the session from `CLAUDE_CODE_REMOTE_SESSION_ID` (override with + `--hub-session` / `MXCLI_HUB_SESSION`). Past sessions are **retained** so you can see + older ones: the hub persists a per-session endpoint history to `--sessions-file` (default + `~/.mxcli/hub-sessions.json`, survives restarts) and prunes it after `--session-retention` + (default 30 days). Re-registering keeps a **stable URL**. - `--hub` **implies `--local`**, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA and `originURI` cookie work), and the tunnel reconnects forever. Combine with `--watch` for the full remote loop: edit here → hot-apply → refresh the tab. diff --git a/docs-site/src/tutorial/claude-code-web.md b/docs-site/src/tutorial/claude-code-web.md index 69bc203f6..0cb20e496 100644 --- a/docs-site/src/tutorial/claude-code-web.md +++ b/docs-site/src/tutorial/claude-code-web.md @@ -63,6 +63,11 @@ sessions; configure two things on it: |----------|-------|-----| | `MXCLI_HUB_KEY` | the hub key from step 2 | mxcli reads it automatically so `run --hub` registers previews as you. Survives container reaping. | +Set the hub key **here, on the environment — never in a committed file.** It's a +credential, and a gitignored file wouldn't survive container recycling anyway; +environment variables are re-injected into every session, so the environment is +the only place it belongs. + **Network policy** The bootstrap prompt downloads a few things on first run, so the environment's diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 2f42c4c6b..0804ea41a 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -122,6 +122,13 @@ alter entity Sales.Customer | Create constant | `create [or modify] constant Module.Name type DataType default 'value';` | String, Integer, Boolean, etc. | | Drop constant | `drop constant Module.Name;` | | +A per-configuration override holds either a **shared** value (in the model, so in +version control) or a **private** one (on the developer's own workstation, out of the +repo). MDL preserves that choice but never changes it: `alter settings constant … value` +is refused on a private override, `show constant values` reports it as `(private)`, and +`describe settings` emits a comment rather than a re-executable statement. +`alter settings drop constant` still works. + **Example:** ```sql create constant MyModule.ApiBaseUrl type string default 'https://api.example.com'; @@ -514,7 +521,7 @@ create or replace navigation Responsive | Alter configuration | `alter settings configuration 'Name' key = value;` | DatabaseType, DatabaseUrl, HttpPortNumber, etc. | | Alter constant | `alter settings constant 'Name' value 'val' in configuration 'cfg';` | Override constant per configuration | | Drop constant override | `alter settings drop constant 'Name' in configuration 'cfg';` | Reset to default value | -| Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration | +| Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration. `DatabaseType` must be `Db2`, `Hsqldb`, `MySql`, `Oracle`, `PostgreSql`, `SapHana` or `SqlServer` (case-insensitive) | | Drop configuration | `drop configuration 'Name';` | Remove a configuration | | Alter language | `alter settings LANGUAGE key = value;` | DefaultLanguageCode | | Alter workflows | `alter settings workflows key = value;` | UserEntity, DefaultTaskParallelism | diff --git a/examples/create_page/main.go b/examples/create_page/main.go index e7af204e6..035e2cfca 100644 --- a/examples/create_page/main.go +++ b/examples/create_page/main.go @@ -320,7 +320,7 @@ func main() { TypeName: "Forms$FormCallArgument", }, ParameterID: model.ID(layoutQualifiedName + ".Main"), - Widget: wrapper, + Widgets: []pages.Widget{wrapper}, }, } diff --git a/mdl-examples/bug-tests/759-create-configuration-storage-names.mdl b/mdl-examples/bug-tests/759-create-configuration-storage-names.mdl new file mode 100644 index 000000000..8b26a0ea4 --- /dev/null +++ b/mdl-examples/bug-tests/759-create-configuration-storage-names.mdl @@ -0,0 +1,64 @@ +-- Bug #759: CREATE CONFIGURATION produced a settings document Studio Pro cannot open +-- +-- Symptom: the configuration is created and `mx check` reports no errors, but +-- Studio Pro throws on the next open of the changed unit (e.g. clicking it in the +-- version-control status grid): +-- +-- System.InvalidOperationException: Sequence contains no matching element +-- at ...MprProperty.cs:line 25 +-- +-- Silently, the same write also reset the ports of every *existing* configuration +-- to 0 — the Default configuration lost http=8080 / server=8090. +-- +-- Three defects, all in what the write puts on disk: +-- +-- 1. DatabaseType was hardcoded "HSQLDB". The Mendix enumeration member is +-- "Hsqldb"; nothing matches "HSQLDB". User-supplied values were not checked +-- at all, so 'postgres' went straight to disk too. +-- 2. The codec engine read the ports through the gen accessors, which bind the +-- SDK names RuntimePortNumber / AdminPortNumber. Studio Pro stores +-- HttpPortNumber / ServerPortNumber, so the read returned 0 for both and the +-- overlay wrote that 0 back onto every configuration. +-- 3. Mendix renamed the runtime Java version property between 11.6 +-- ("JavaVersion" = "Java21") and 11.12 ("JavaMajorVersion" = "21"). mxcli +-- wrote the 11.6 name unconditionally, so on an 11.12 project every settings +-- write left JavaMajorVersion stale and added a JavaVersion property that +-- version's metamodel does not define — which is the property lookup that +-- fails. +-- +-- mxbuild does not catch any of this: its deserializer tolerates unknown +-- properties, so `mx check` passes on the corrupted document. +-- +-- Manual verification (needs a project, so it is not part of `make check-mdl`'s +-- syntax pass): +-- +-- 1. mxcli exec 759-create-configuration-storage-names.mdl -p app.mpr +-- 2. `show settings` — the Default configuration still reports http=8080, and +-- Model Settings still reports its Java version. +-- 3. Reopen in Studio Pro and click the changed item in the Changes pane: it +-- opens. Before the fix it threw at MprProperty.cs:25. +-- 4. `mx check app.mpr` reports no new errors (it did before the fix too). +-- +-- A BSON-level check is stronger and needs no Studio Pro: dump the +-- Settings$ProjectSettings unit before and after this script and diff key by key. +-- The only difference must be the two added configurations. + +-- Default values: DatabaseType must land on the enum member "Hsqldb", and both +-- ports must be set (8080 / 8090) so the new configuration is runnable. +create configuration 'Issue759 Defaults'; + +-- User-supplied values are canonicalised to the enum's spelling, whatever case +-- they were typed in. 'postgresql' below must be stored as "PostgreSql". +create configuration 'Issue759 Postgres' + DatabaseType = 'postgresql', + DatabaseName = 'issue759', + DatabaseUserName = 'mendix', + HttpPortNumber = 8081, + ServerPortNumber = 8091; + +-- ALTER goes through the same canonicalisation. +alter settings configuration 'Issue759 Postgres' + DatabaseType = 'PostgreSql', + ApplicationRootUrl = 'http://localhost:8081/'; + +show settings; diff --git a/mdl-examples/bug-tests/760-no-placeholder-wrapper.mdl b/mdl-examples/bug-tests/760-no-placeholder-wrapper.mdl new file mode 100644 index 000000000..5677031fe --- /dev/null +++ b/mdl-examples/bug-tests/760-no-placeholder-wrapper.mdl @@ -0,0 +1,51 @@ +-- Bug #760: every mxcli-authored page gained a container nobody asked for. +-- +-- The builder wrapped each non-empty layout placeholder in a synthetic +-- Forms$DivContainer named "conditionalVisibilityWidget", so creating a single +-- button produced a button AND a container. It showed up in the Studio Pro widget +-- tree of every page mxcli wrote. +-- +-- The wrapper was never a BSON requirement: Forms$FormCallArgument carries a +-- `Widgets` array and a Studio Pro page fills it with its top-level widgets +-- directly. It existed only because pages.LayoutCallArgument declared a single +-- `Widget` field. Verified against Mendix's own output — +-- Administration.Account_Overview in a `mx create-project` app has two top-level +-- widgets in one placeholder and zero wrappers. +-- +-- Verify without Studio Pro (both engines must agree, and no wrapper may appear): +-- +-- mxcli exec 760-no-placeholder-wrapper.mdl -p app.mpr +-- mxcli bson dump -p app.mpr --type page --object Issue760.OneWidget \ +-- | grep -c conditionalVisibilityWidget # expect 0 +-- mxcli docker check -p app.mpr # expect 0 errors +-- +-- Pages authored by older mxcli still contain the wrapper; DESCRIBE and the catalog +-- keep unwrapping it, so those projects still read correctly. + +create module Issue760; +create module role Issue760.User; + +-- The reported case: one widget in, one widget out. +create or replace page Issue760.OneWidget ( + title: 'One widget', + Layout: Atlas_Core.Atlas_Default +) +{ + actionbutton btnOnly (caption: 'Only widget', action: close_page) +} +/ + +-- The case the wrapper was introduced for: several top-level widgets go into the +-- placeholder's Widgets array side by side, not inside a container. +create or replace page Issue760.SeveralWidgets ( + title: 'Several widgets', + Layout: Atlas_Core.Atlas_Default +) +{ + dynamictext dtTitle (content: 'Heading', rendermode: H1) + actionbutton btnA (caption: 'A', action: close_page) + container cAuthored { + dynamictext dtNested (content: 'A container the author DID write — must survive') + } +} +/ diff --git a/mdl-examples/bug-tests/762-813-dataview-properties.mdl b/mdl-examples/bug-tests/762-813-dataview-properties.mdl new file mode 100644 index 000000000..2d75b9780 --- /dev/null +++ b/mdl-examples/bug-tests/762-813-dataview-properties.mdl @@ -0,0 +1,75 @@ +-- Bugs #762 and #813: two DataView properties that parsed, passed `check`, and were +-- then silently discarded. +-- +-- #762 FormOrientation: Vertical had no effect on the DEFAULT (modelsdk) engine. +-- Studio Pro's "Form orientation" radio has no BSON field of its own — it IS +-- LabelWidth (0 = Vertical, 3 = Horizontal, Mendix's default). Only the legacy +-- writer performed that translation; the modelsdk writer emitted LabelWidth solely +-- when an explicit `LabelWidth:` was given, so the orientation was read into the +-- model and dropped. Same shape as #812's OverridePageTitle: a field set on the +-- model that no writer on the active engine reads. +-- +-- #813 showFooter was not settable at all. ShowFooter was only ever set implicitly, +-- by the presence of a `footer { … }` block, so there was no way to show an empty +-- footer or to declare footer widgets that start hidden. The property sat in the +-- validator's allow-list, so it parsed clean and was thrown away. +-- +-- Verify without Studio Pro — LabelWidth must be [0, 3, 5, 3] and ShowFooter +-- [false, false, false, true], identically on both engines: +-- +-- mxcli exec 762-813-dataview-properties.mdl -p app.mpr +-- mxcli bson dump -p app.mpr --type page --object Issue762.DVTest +-- MXCLI_ENGINE=legacy mxcli exec ... # must produce the same values +-- +-- And DESCRIBE must round-trip all four: +-- dvVert -> FormOrientation: Vertical dvHoriz -> (nothing; 3 is the default) +-- dvExplicit -> LabelWidth: 5 dvFoot -> ShowFooter: true + +create module Issue762; +create module role Issue762.User; + +@position(100, 100) +create persistent entity Issue762.Thing ( + Name: string(100) +); + +create or replace page Issue762.DVTest ( + title: 'DataView properties', + Layout: Atlas_Core.Atlas_Default, + params: { $Thing: Issue762.Thing } +) +{ + container c1 { + -- #762: label above the input. + dataview dvVert (datasource: $Thing, FormOrientation: Vertical) { + textbox t1 (attribute: Name) + } + + -- The default; describe omits it because LabelWidth 3 is Mendix's default. + dataview dvHoriz (datasource: $Thing, FormOrientation: Horizontal) { + textbox t2 (attribute: Name) + } + + -- An explicit LabelWidth is the more specific statement and wins over + -- FormOrientation. + dataview dvExplicit (datasource: $Thing, LabelWidth: 5) { + textbox t3 (attribute: Name) + } + + -- #813: an empty footer, shown. Previously impossible — ShowFooter could only be + -- turned on by declaring footer widgets. + dataview dvFoot (datasource: $Thing, showFooter: true) { + textbox t4 (attribute: Name) + } + + -- The other direction: footer widgets declared but hidden. An explicit value + -- wins over the `footer { … }` block, and hiding must not discard the widgets. + dataview dvHidden (datasource: $Thing, showFooter: false) { + textbox t5 (attribute: Name) + footer f1 { + dynamictext ft1 (content: 'hidden footer') + } + } + } +} +/ diff --git a/mdl-examples/bug-tests/private-constant-values.mdl b/mdl-examples/bug-tests/private-constant-values.mdl new file mode 100644 index 000000000..c1fad01e4 --- /dev/null +++ b/mdl-examples/bug-tests/private-constant-values.mdl @@ -0,0 +1,68 @@ +-- Bug: any settings write corrupted a PRIVATE constant override +-- +-- A constant override's value is stored one of two ways: +-- +-- Settings$SharedValue carries a "Value" — lives in the model, so in git, +-- and every developer gets it +-- Settings$PrivateValue a MARKER TYPE WITH NO PROPERTIES — the value is on +-- the developer's own workstation, deliberately kept +-- out of version control (typical for dev API tokens) +-- +-- The overlay assumed every stored SharedOrPrivateValue was a SharedValue and +-- assigned cv.Value into it. For a private override cv.Value is always "" (the +-- value is not in the model at all), so any settings write produced: +-- +-- {"$Type": "Settings$PrivateValue", "Value": ""} +-- +-- ...a property that type does not define. Studio Pro resolves each stored +-- property against the type's property list and throws on open: +-- +-- System.InvalidOperationException: Sequence contains no matching element +-- at ...MprProperty.cs:line 25 +-- +-- Blast radius: configurations are shared in version control, so one developer +-- running any ALTER SETTINGS or CREATE CONFIGURATION corrupted every developer's +-- private overrides and pushed the result. mxbuild does not catch it — `mx check` +-- passes on the corrupted document. +-- +-- Separately, `describe settings` rendered a private override as `value ''`, so +-- replaying describe's own output converted it into a SHARED empty override — +-- publishing into git a value the developer chose to keep local. +-- +-- Fix: model.ConstantValue.IsPrivate carries the distinction, and MDL preserves +-- the choice but never authors it. The shared/private decision belongs to the +-- constant; configurations just respect it. +-- +-- This script cannot create the private override — that is exactly what MDL +-- refuses to author — so the repro is manual: +-- +-- 1. In Studio Pro, override a constant in a configuration and set its value +-- to private. Save and close. +-- 2. Run this script: +-- mxcli exec private-constant-values.mdl -p app.mpr +-- 3. Dump the settings unit and confirm the override still reads +-- {"$Type": "Settings$PrivateValue"} +-- with NO "Value" key. Before the fix it carried "Value": "". +-- 4. Reopen in Studio Pro: it opens, and the override is still private with the +-- developer's local value intact. Before the fix it threw at MprProperty.cs:25. +-- 5. `mxcli -p app.mpr -c "describe settings"` reports the private override as a +-- comment, not as an `alter settings constant` line. +-- +-- Substitute your own constant/configuration names below. + +-- An unrelated edit: nothing here mentions the constant, but the whole settings +-- document is rewritten. This is the write that used to corrupt the override. +alter settings configuration 'Default' + HttpPortNumber = 8099; + +-- Setting a value on a private override is refused rather than silently converting +-- it to a shared one. Uncomment against a project that has one, and expect: +-- Error: constant 'X' has a private value in configuration 'Default'; ... +-- alter settings constant 'MyModule.ApiToken' value 'leaked-into-git' +-- in configuration 'Default'; + +-- Dropping the override is still allowed: it removes the private marker along with +-- everything else, which is what was asked for. +-- alter settings drop constant 'MyModule.ApiToken' in configuration 'Default'; + +describe settings; diff --git a/mdl/backend/mcp/page.go b/mdl/backend/mcp/page.go index 1f7d003d7..a56452799 100644 --- a/mdl/backend/mcp/page.go +++ b/mdl/backend/mcp/page.go @@ -41,17 +41,21 @@ func (b *Backend) CreatePage(page *pages.Page) error { slotWidgets := make([]any, 0) if page.LayoutCall != nil { for i, arg := range page.LayoutCall.Arguments { - if arg.Widget == nil { + if len(arg.Widgets) == 0 { continue } - w, err := b.mapPageWidget(arg.Widget) - if err != nil { - return fmt.Errorf("page %q: %w", page.Name, err) + mapped := make([]any, 0, len(arg.Widgets)) + for _, aw := range arg.Widgets { + w, err := b.mapPageWidget(aw) + if err != nil { + return fmt.Errorf("page %q: %w", page.Name, err) + } + mapped = append(mapped, w) } slotWidgets = append(slotWidgets, map[string]any{ "$Type": "Pages$Content", "slot": slotName(arg, i), - "widgets": []any{w}, + "widgets": mapped, }) } } diff --git a/mdl/backend/modelsdk/page_write.go b/mdl/backend/modelsdk/page_write.go index c2e47331f..7d89d984d 100644 --- a/mdl/backend/modelsdk/page_write.go +++ b/mdl/backend/modelsdk/page_write.go @@ -192,8 +192,8 @@ func layoutCallToGen(lc *pages.LayoutCall) (*genPg.LayoutCall, error) { ga.SetTypeName("Forms$FormCallArgument") assignID(ga) ga.SetParameterQualifiedName(string(arg.ParameterID)) - if arg.Widget != nil { - wg, err := widgetToGen(arg.Widget) + for _, w := range arg.Widgets { + wg, err := widgetToGen(w) if err != nil { return nil, err } diff --git a/mdl/backend/modelsdk/settings_read.go b/mdl/backend/modelsdk/settings_read.go index fa2236344..47942bd69 100644 --- a/mdl/backend/modelsdk/settings_read.go +++ b/mdl/backend/modelsdk/settings_read.go @@ -130,6 +130,30 @@ func projectSettingsFromGen(g *genSet.ProjectSettings) *model.ProjectSettings { return ps } +// rawInt reads an integer property from an element's stored BSON, falling back to +// the value the gen accessor produced when the key is absent or not an integer. +func rawInt(el element.Element, key string, fallback int32) int { + v := el.Raw().Lookup(key) + if i, ok := v.Int32OK(); ok { + return int(i) + } + if i, ok := v.Int64OK(); ok { + return int(i) + } + return int(fallback) +} + +// javaVersionOf reads the runtime Java version under whichever key this Mendix +// version stores it: 11.6 writes "JavaVersion" ("Java21"), 11.12 renamed it to +// "JavaMajorVersion" ("21") and the gen accessor only knows the former. +// See settingsoverlay.JavaVersionKey (mendixlabs/mxcli#759). +func javaVersionOf(p *genSet.RuntimeSettings) string { + if v, ok := p.Raw().Lookup("JavaMajorVersion").StringValueOK(); ok { + return v + } + return p.JavaVersion() +} + func modelSettingsFromGen(p *genSet.RuntimeSettings) *model.ModelSettings { ms := &model.ModelSettings{ AfterStartupMicroflow: p.AfterStartupMicroflowQualifiedName(), @@ -138,7 +162,7 @@ func modelSettingsFromGen(p *genSet.RuntimeSettings) *model.ModelSettings { AllowUserMultipleSessions: p.AllowUserMultipleSessions(), HashAlgorithm: p.HashAlgorithm(), BcryptCost: int(p.BcryptCost()), - JavaVersion: p.JavaVersion(), + JavaVersion: javaVersionOf(p), RoundingMode: p.RoundingMode(), ScheduledEventTimeZoneCode: p.ScheduledEventTimeZoneCode(), FirstDayOfWeek: p.FirstDayOfWeek(), @@ -166,11 +190,18 @@ func configurationSettingsFromGen(p *genSet.ConfigurationSettings) *model.Config DatabaseUserName: cfg.DatabaseUserName(), DatabasePassword: cfg.DatabasePassword(), DatabaseUseIntegratedSecurity: cfg.DatabaseUseIntegratedSecurity(), - HttpPortNumber: int(cfg.RuntimePortNumber()), - ServerPortNumber: int(cfg.AdminPortNumber()), - ApplicationRootUrl: cfg.ApplicationRootUrl(), - MaxJavaHeapSize: int(cfg.MaxJavaHeapSize()), - ExtraJvmParameters: cfg.ExtraJvmParameters(), + // The gen Configuration binds the two ports under their SDK names + // (RuntimePortNumber / AdminPortNumber); Studio Pro stores them as + // HttpPortNumber / ServerPortNumber, so the accessors always returned 0 + // and the overlay wrote that 0 straight back — every existing + // configuration lost its ports on any settings write + // (mendixlabs/mxcli#759). Read the stored keys, keeping the accessors as + // the fallback in case a future version adopts the SDK spelling. + HttpPortNumber: rawInt(cfg, "HttpPortNumber", cfg.RuntimePortNumber()), + ServerPortNumber: rawInt(cfg, "ServerPortNumber", cfg.AdminPortNumber()), + ApplicationRootUrl: cfg.ApplicationRootUrl(), + MaxJavaHeapSize: int(cfg.MaxJavaHeapSize()), + ExtraJvmParameters: cfg.ExtraJvmParameters(), } setBase(&sc.BaseElement, cfg, "Settings$Configuration") for _, cvEl := range cfg.ConstantValuesItems() { @@ -190,6 +221,7 @@ func configurationSettingsFromGen(p *genSet.ConfigurationSettings) *model.Config mcv := &model.ConstantValue{ ConstantId: constantID, Value: constantValueOf(cv), + IsPrivate: isPrivateConstantValue(cv), } setBase(&mcv.BaseElement, cv, "Settings$ConstantValue") sc.ConstantValues = append(sc.ConstantValues, mcv) @@ -218,8 +250,24 @@ func languageSettingsFromGen(p *genSet.LanguageSettings) *model.LanguageSettings return ls } +// isPrivateConstantValue reports whether an override's value is private — stored +// on the developer's workstation rather than in the shared model. Studio Pro marks +// that by nesting a Settings$PrivateValue, a type with no properties at all, in +// place of the Settings$SharedValue that would carry a value. +// +// The gen registry may not have a factory for the marker, so the decoded child can +// be a bare element.Base; match on the type name rather than the Go type. +func isPrivateConstantValue(cv *genSet.ConstantValue) bool { + spv := cv.SharedOrPrivateValue() + if spv == nil { + return false + } + return spv.TypeName() == "Settings$PrivateValue" +} + // constantValueOf extracts a constant's configured value. The value lives in the -// nested SharedOrPrivateValue (a SharedValue); private values are not stored. +// nested SharedOrPrivateValue (a SharedValue); a private value is not in the model +// at all, so this returns "" and isPrivateConstantValue tells the two apart. func constantValueOf(cv *genSet.ConstantValue) string { if v := cv.Value(); v != "" { return v diff --git a/mdl/backend/modelsdk/settings_write.go b/mdl/backend/modelsdk/settings_write.go index 3812c8ac9..f5a0a1b8d 100644 --- a/mdl/backend/modelsdk/settings_write.go +++ b/mdl/backend/modelsdk/settings_write.go @@ -92,7 +92,7 @@ func overlayModelSettings(ms *model.ModelSettings, raw map[string]any) map[strin raw["AllowUserMultipleSessions"] = ms.AllowUserMultipleSessions raw["HashAlgorithm"] = ms.HashAlgorithm raw["BcryptCost"] = settingsoverlay.SafeInt64(ms.BcryptCost) - raw["JavaVersion"] = ms.JavaVersion + settingsoverlay.SetJavaVersion(raw, ms.JavaVersion) raw["RoundingMode"] = ms.RoundingMode raw["ScheduledEventTimeZoneCode"] = ms.ScheduledEventTimeZoneCode raw["FirstDayOfWeek"] = ms.FirstDayOfWeek diff --git a/mdl/backend/modelsdk/settings_write_759_test.go b/mdl/backend/modelsdk/settings_write_759_test.go new file mode 100644 index 000000000..51a475cca --- /dev/null +++ b/mdl/backend/modelsdk/settings_write_759_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/bsonutil" + "github.com/mendixlabs/mxcli/mdl/settingsoverlay" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" +) + +// writeSettingsBackUnchanged performs the smallest possible settings write: read +// the document and hand it straight back. Every ALTER SETTINGS / CREATE +// CONFIGURATION goes through this same read-modify-write, so whatever this loses +// they all lose. +func writeSettingsBackUnchanged(t *testing.T, proj string) { + t.Helper() + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + ps, err := b.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings: %v", err) + } + if err := b.UpdateProjectSettings(ps); err != nil { + t.Fatalf("UpdateProjectSettings: %v", err) + } +} + +// TestUpdateProjectSettings_PreservesPorts is the regression test for +// mendixlabs/mxcli#759: the gen Configuration binds the two ports under their SDK +// names (RuntimePortNumber / AdminPortNumber) while Studio Pro stores +// HttpPortNumber / ServerPortNumber, so the read returned 0 for both and the +// overlay wrote that 0 back — every settings write silently reset the ports of +// every existing configuration. +func TestUpdateProjectSettings_PreservesPorts(t *testing.T) { + proj := copyFixture(t) + + before := readConfiguration(t, proj) + wantHTTP, wantServer := before["HttpPortNumber"], before["ServerPortNumber"] + if toInt(wantHTTP) == 0 || toInt(wantServer) == 0 { + t.Fatalf("fixture precondition: expected non-zero ports, got http=%v server=%v", + wantHTTP, wantServer) + } + + writeSettingsBackUnchanged(t, proj) + + after := readConfiguration(t, proj) + if got := toInt(after["HttpPortNumber"]); got != toInt(wantHTTP) { + t.Errorf("HttpPortNumber = %d, want %d", got, toInt(wantHTTP)) + } + if got := toInt(after["ServerPortNumber"]); got != toInt(wantServer) { + t.Errorf("ServerPortNumber = %d, want %d", got, toInt(wantServer)) + } +} + +// TestUpdateProjectSettings_JavaVersionKeyFollowsDocument covers the second half of +// #759. Mendix renamed the runtime Java version property between 11.6 +// ("JavaVersion" = "Java21") and 11.12 ("JavaMajorVersion" = "21"). mxcli wrote the +// 11.6 name unconditionally, so on an 11.12 project a settings write left +// JavaMajorVersion stale and added a JavaVersion property that version's metamodel +// does not define — which is what Studio Pro fails to resolve on the next open. +func TestUpdateProjectSettings_JavaVersionKeyFollowsDocument(t *testing.T) { + tests := []struct { + name string + storedKey string + value string + }{ + {name: "mendix_11_6", storedKey: "JavaVersion", value: "Java21"}, + {name: "mendix_11_12", storedKey: "JavaMajorVersion", value: "21"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + proj := copyFixture(t) + seedJavaVersionKey(t, proj, tc.storedKey, tc.value) + + writeSettingsBackUnchanged(t, proj) + + ms := readModelSettings(t, proj) + if got := ms[tc.storedKey]; got != tc.value { + t.Errorf("%s = %v, want %q", tc.storedKey, got, tc.value) + } + for _, other := range []string{"JavaVersion", "JavaMajorVersion"} { + if other == tc.storedKey { + continue + } + if v, ok := ms[other]; ok { + t.Errorf("write invented %s = %v; this Mendix version stores %s", + other, v, tc.storedKey) + } + } + }) + } +} + +// seedJavaVersionKey rewrites the fixture's Settings$ModelSettings part so it +// carries exactly one Java-version key, standing in for the Mendix version that +// spells it that way. +func seedJavaVersionKey(t *testing.T, proj, key, value string) { + t.Helper() + mutateSettingsPart(t, proj, "Settings$ModelSettings", func(part map[string]any) { + delete(part, "JavaVersion") + delete(part, "JavaMajorVersion") + part[key] = value + }) +} + +// mutateSettingsPart applies fn to the named part of the Settings$ProjectSettings +// unit and writes the document back. +func mutateSettingsPart(t *testing.T, proj, typeName string, fn func(map[string]any)) { + t.Helper() + r, err := mmpr.OpenWithOptions(proj, mmpr.OpenOptions{ReadOnly: false}) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + defer r.Close() + refs, err := r.ListUnitsByType("Settings$ProjectSettings") + if err != nil || len(refs) != 1 { + t.Fatalf("ListUnitsByType = %v, %v", refs, err) + } + raw, err := r.GetRawUnitBytes(refs[0].ID) + if err != nil { + t.Fatalf("GetRawUnitBytes: %v", err) + } + var doc bson.M + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + found := false + for _, part := range settingsoverlay.ArrayElements(doc["Settings"]) { + if part["$Type"] == typeName { + fn(part) + found = true + } + } + if !found { + t.Fatalf("fixture has no %s part", typeName) + } + contents, err := bson.Marshal(bsonutil.OrderStorageValue(doc)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := mmpr.NewWriterWithReader(r).UpdateRawUnit(refs[0].ID, contents); err != nil { + t.Fatalf("UpdateRawUnit: %v", err) + } +} + +// readModelSettings returns the raw Settings$ModelSettings part from disk. +func readModelSettings(t *testing.T, proj string) map[string]any { + t.Helper() + r, err := mmpr.Open(proj) + if err != nil { + t.Fatalf("open: %v", err) + } + defer r.Close() + refs, err := r.ListUnitsByType("Settings$ProjectSettings") + if err != nil || len(refs) != 1 { + t.Fatalf("ListUnitsByType = %v, %v", refs, err) + } + raw, err := r.GetRawUnitBytes(refs[0].ID) + if err != nil { + t.Fatalf("GetRawUnitBytes: %v", err) + } + var doc bson.M + if err := bson.Unmarshal(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, part := range settingsoverlay.ArrayElements(doc["Settings"]) { + if part["$Type"] == "Settings$ModelSettings" { + return part + } + } + t.Fatal("no Settings$ModelSettings part on disk") + return nil +} + +func toInt(v any) int { + switch n := v.(type) { + case int32: + return int(n) + case int64: + return int(n) + case int: + return n + } + return 0 +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 3c936d34e..cb5016d55 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -332,9 +332,12 @@ func widgetToGen(w pages.Widget) (element.Element, error) { g.SetEditability(editability(x.ReadOnly)) g.SetReadOnlyStyle("Control") g.SetShowFooter(x.ShowFooter) - if x.LabelWidth != nil { - g.SetLabelWidth(int32(*x.LabelWidth)) - } + // Always emit LabelWidth. It carries Studio Pro's "Form orientation" radio, + // which has no BSON field of its own — so writing it only when an explicit + // `LabelWidth:` was given dropped `FormOrientation: Vertical` entirely + // (mendixlabs/mxcli#762). The resolution rule lives on the model, shared with + // the legacy writer. + g.SetLabelWidth(int32(x.ResolvedLabelWidth())) g.SetNoEntityMessage(captionToGen(x.NoEntityMessage)) for _, c := range x.Widgets { cg, err := widgetToGen(c) diff --git a/mdl/executor/cmd_constants.go b/mdl/executor/cmd_constants.go index f66bd94d5..a19a98a01 100644 --- a/mdl/executor/cmd_constants.go +++ b/mdl/executor/cmd_constants.go @@ -340,6 +340,11 @@ func createConstant(ctx *ExecContext, stmt *ast.CreateConstantStmt) error { return nil } +// privateValueLabel marks a constant override whose value is private: stored on the +// developer's workstation rather than in the shared model, so mxcli can report that +// the override exists but never its value. +const privateValueLabel = "(private)" + // listConstantValues handles SHOW CONSTANT VALUES command. // Displays one row per constant per configuration for easy comparison. func listConstantValues(ctx *ExecContext, moduleName string) error { @@ -395,6 +400,14 @@ func listConstantValues(ctx *ExecContext, moduleName string) error { configNames = append(configNames, cfg.Name) m := make(map[string]string) for _, cv := range cfg.ConstantValues { + // A private override's value is on the developer's workstation, not in + // the model, so cv.Value is always "". Rendering that as an empty cell + // would be indistinguishable from an override deliberately set to the + // empty string — say which it is. + if cv.IsPrivate { + m[cv.ConstantId] = privateValueLabel + continue + } m[cv.ConstantId] = cv.Value } configValues[cfg.Name] = m diff --git a/mdl/executor/cmd_constants_mock_test.go b/mdl/executor/cmd_constants_mock_test.go index 913442af9..ae5133d4b 100644 --- a/mdl/executor/cmd_constants_mock_test.go +++ b/mdl/executor/cmd_constants_mock_test.go @@ -107,3 +107,44 @@ func TestDescribeConstant_Mock_NotFound(t *testing.T) { // Backend error: cmd_error_mock_test.go (TestShowConstants_Mock_BackendError) // JSON: cmd_json_mock_test.go (TestShowConstants_Mock_JSON) + +// TestShowConstantValues_MarksPrivateOverride: a private override's value lives on +// the developer's workstation, so model.ConstantValue.Value is always "". Rendering +// that as an empty cell was indistinguishable from an override deliberately set to +// the empty string — the table must say which it is. +func TestShowConstantValues_MarksPrivateOverride(t *testing.T) { + mod := mkModule("MyModule") + token := mkConstant(mod.ID, "ApiToken", "String", "default-token") + base := mkConstant(mod.ID, "BaseUrl", "String", "https://example.invalid") + + h := mkHierarchy(mod) + withContainer(h, token.ContainerID, mod.ID) + withContainer(h, base.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListConstantsFunc: func() ([]*model.Constant, error) { return []*model.Constant{token, base}, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{ + Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{{ + Name: "Default", + ConstantValues: []*model.ConstantValue{ + {ConstantId: "MyModule.ApiToken", IsPrivate: true}, + {ConstantId: "MyModule.BaseUrl", Value: "https://staging.invalid"}, + }, + }}, + }, + }, nil + }, + } + + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, listConstantValues(ctx, "")) + + out := buf.String() + assertContainsStr(t, out, "(private)") + // The shared override must still show its value, and the private one must not + // be reported as an ordinary empty override. + assertContainsStr(t, out, "https://staging.invalid") +} diff --git a/mdl/executor/cmd_pages_builder_dataview_test.go b/mdl/executor/cmd_pages_builder_dataview_test.go new file mode 100644 index 000000000..473ab5aef --- /dev/null +++ b/mdl/executor/cmd_pages_builder_dataview_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#813: `dataview dv (…, showFooter: true)` parsed, passed `check`, +// and was silently discarded. ShowFooter was only ever set implicitly, by the +// presence of a `footer { … }` block, so there was no way to show an empty footer and +// no way to declare footer widgets that start hidden. +// +// Two traps sat behind it, both of which produce a silent false rather than an error: +// the property key arrives with the author's casing, and GetBoolProp is +// case-SENSITIVE (unlike GetStringProp) and accepts only a real bool. +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" +) + +func dataViewWith(props map[string]any, children ...*ast.WidgetV3) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "dataview", Name: "dv", Properties: props, Children: children} +} + +func footerBlock() *ast.WidgetV3 { + return &ast.WidgetV3{Type: "footer", Name: "f", Children: []*ast.WidgetV3{ + {Type: "text", Name: "t", Properties: map[string]any{"Content": "x"}}, + }} +} + +func TestBuildDataView_ShowFooter(t *testing.T) { + tests := []struct { + name string + widget *ast.WidgetV3 + want bool + wantErr bool + wantFoot int + }{ + {"absent and no footer block", dataViewWith(map[string]any{}), false, false, 0}, + {"footer block implies true", dataViewWith(map[string]any{}, footerBlock()), true, false, 1}, + + // The reported case. Also covers the casing trap: the author writes + // `showFooter:` and the lookup must match it. + {"explicit lowercase true", dataViewWith(map[string]any{"showFooter": true}), true, false, 0}, + {"explicit canonical true", dataViewWith(map[string]any{"ShowFooter": true}), true, false, 0}, + + // The value may arrive as a string depending on how it was written; a bare + // GetBoolProp would read that as false rather than complaining. + {"string true", dataViewWith(map[string]any{"showFooter": "true"}), true, false, 0}, + {"string false", dataViewWith(map[string]any{"showFooter": "false"}), false, false, 0}, + + // Explicit wins over the footer block in both directions. + {"explicit false with footer block", dataViewWith(map[string]any{"showFooter": false}, footerBlock()), false, false, 1}, + {"explicit true without footer block", dataViewWith(map[string]any{"showFooter": true}), true, false, 0}, + + // A nonsense value must be refused, not silently treated as false — that is + // the failure mode this whole fix is about. + {"invalid value is refused", dataViewWith(map[string]any{"showFooter": "maybe"}), false, true, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pb := &pageBuilder{paramEntityNames: map[string]string{}, widgetScope: map[string]model.ID{}} + dv, err := pb.buildDataViewV3(tc.widget) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error for an invalid ShowFooter value, got none") + } + return + } + if err != nil { + t.Fatalf("buildDataViewV3: %v", err) + } + if dv.ShowFooter != tc.want { + t.Errorf("ShowFooter = %v, want %v (#813)", dv.ShowFooter, tc.want) + } + if len(dv.FooterWidgets) != tc.wantFoot { + t.Errorf("FooterWidgets = %d, want %d — hiding a footer must not discard its widgets", + len(dv.FooterWidgets), tc.wantFoot) + } + }) + } +} + +// TestBuildDataView_FormOrientation covers the parse half of #762; the write half +// (that it reaches BSON as LabelWidth) is pinned by ResolvedLabelWidth in sdk/pages +// and by the modelsdk writer test. +func TestBuildDataView_FormOrientation(t *testing.T) { + tests := []struct { + name string + props map[string]any + wantLW int + wantErr bool + }{ + {"vertical", map[string]any{"FormOrientation": "Vertical"}, 0, false}, + {"horizontal", map[string]any{"FormOrientation": "Horizontal"}, 3, false}, + {"lowercase accepted", map[string]any{"FormOrientation": "vertical"}, 0, false}, + {"unset defaults to horizontal", map[string]any{}, 3, false}, + {"explicit LabelWidth wins", map[string]any{"FormOrientation": "Vertical", "LabelWidth": 4}, 4, false}, + {"invalid orientation refused", map[string]any{"FormOrientation": "Sideways"}, 0, true}, + {"out-of-range LabelWidth refused", map[string]any{"LabelWidth": 13}, 0, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pb := &pageBuilder{paramEntityNames: map[string]string{}, widgetScope: map[string]model.ID{}} + dv, err := pb.buildDataViewV3(dataViewWith(tc.props)) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error, got none") + } + return + } + if err != nil { + t.Fatalf("buildDataViewV3: %v", err) + } + if got := dv.ResolvedLabelWidth(); got != tc.wantLW { + t.Errorf("ResolvedLabelWidth() = %d, want %d (#762)", got, tc.wantLW) + } + }) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 450cfa16b..a6cd3d14c 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -185,7 +185,6 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) byName[name] = append(byName[name], ph.Widgets...) } - wrapperIdx := 0 for _, name := range order { arg := &pages.LayoutCallArgument{ BaseElement: model.BaseElement{ @@ -194,17 +193,11 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) }, ParameterID: model.ID(s.Layout + "." + name), } + // The placeholder's widgets go in directly. Forms$FormCallArgument carries a + // Widgets array and Studio Pro fills it with the page's top-level widgets; + // wrapping them in a synthetic DivContainer added a phantom container to + // every mxcli-authored page (#760). if widgets := byName[name]; len(widgets) > 0 { - wrapperIdx++ - containerWidget := &pages.Container{ - BaseWidget: pages.BaseWidget{ - BaseElement: model.BaseElement{ - ID: model.ID(types.GenerateID()), - TypeName: "Forms$DivContainer", - }, - Name: fmt.Sprintf("conditionalVisibilityWidget%d", wrapperIdx), - }, - } expanded, err := pb.expandFragments(widgets) if err != nil { return nil, err @@ -214,9 +207,8 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) if err != nil { return nil, mdlerrors.NewBackend("build widget", err) } - containerWidget.Widgets = append(containerWidget.Widgets, w) + arg.Widgets = append(arg.Widgets, w) } - arg.Widget = containerWidget } page.LayoutCall.Arguments = append(page.LayoutCall.Arguments, arg) } diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 85d970337..f43d4a7f8 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -44,6 +44,21 @@ func (pb *pageBuilder) buildDataViewV3(w *ast.WidgetV3) (*pages.DataView, error) dv.LabelWidth = &lw } + // ShowFooter was previously only ever set implicitly, by the presence of a + // `footer { … }` block — so `showFooter: true` parsed, passed `check`, and was + // silently discarded (mendixlabs/mxcli#813). An explicit value is the author's + // statement and wins over the footer block, in both directions: it can show an + // empty footer, or hide one whose widgets are still declared. + showFooterSet := false + if raw, ok := lookupPropCI(w, "ShowFooter"); ok { + v, err := propBool(raw) + if err != nil { + return nil, mdlerrors.NewBackend("dataview ShowFooter", err) + } + dv.ShowFooter = v + showFooterSet = true + } + // Handle DataSource if ds := w.GetDataSource(); ds != nil { // A DataView shows a single object; Mendix offers only Context / Microflow / @@ -81,7 +96,9 @@ func (pb *pageBuilder) buildDataViewV3(w *ast.WidgetV3) (*pages.DataView, error) for _, child := range w.Children { // Check if this is a FOOTER widget - its children go to FooterWidgets if child.Type == "footer" { - dv.ShowFooter = true + if !showFooterSet { + dv.ShowFooter = true + } for _, fw := range child.Children { widget, err := pb.buildWidgetV3(fw) if err != nil { @@ -100,7 +117,9 @@ func (pb *pageBuilder) buildDataViewV3(w *ast.WidgetV3) (*pages.DataView, error) // Also build footer widgets from Properties (legacy support) if footerWidgets, ok := w.Properties["Footer"].([]*ast.WidgetV3); ok { - dv.ShowFooter = true + if !showFooterSet { + dv.ShowFooter = true + } for _, fw := range footerWidgets { widget, err := pb.buildWidgetV3(fw) if err != nil { @@ -1057,3 +1076,40 @@ func dataGridFilterWidgetID(widgetType string) string { } return "" } + +// lookupPropCI reports whether a property is present, matching the key +// case-insensitively the way GetStringProp/GetBoolProp resolve values. Presence has +// to be tested the same way the value is read, or `showFooter:` would be read as +// absent while `ShowFooter:` was honoured. +func lookupPropCI(w *ast.WidgetV3, key string) (any, bool) { + if v, ok := w.Properties[key]; ok { + return v, true + } + lower := strings.ToLower(key) + for k, v := range w.Properties { + if strings.ToLower(k) == lower { + return v, true + } + } + return nil, false +} + +// propBool coerces a widget property value to a bool. GetBoolProp cannot be used +// here: it is case-SENSITIVE (unlike GetStringProp) and accepts only a real bool, so +// `showFooter: true` silently read as false — the property was found and its value +// discarded, which is the same silent-drop this fix is closing. +func propBool(v any) (bool, error) { + switch x := v.(type) { + case bool: + return x, nil + case string: + switch strings.ToLower(x) { + case "true", "yes": + return true, nil + case "false", "no": + return false, nil + } + return false, fmt.Errorf("invalid value %q (expected true or false)", x) + } + return false, fmt.Errorf("invalid value %v (expected true or false)", v) +} diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 6e5f57e10..f58d68b18 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -631,6 +631,9 @@ type rawWidget struct { WidgetID string // DataView-only: LabelWidth read from BSON (-1 = not set, 0..12 = explicit) LabelWidth int + // DataView-only: ShowFooter read from BSON. Round-tripping it matters only when + // the footer block would not imply the same value (#813). + ShowFooter bool // Pluggable Image widget properties ImageUrl string // Image URL (from textTemplate) AlternativeText string // Alt text (from textTemplate) diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index f31d728e7..375ce5c65 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -349,6 +349,12 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { case w.LabelWidth > 0 && w.LabelWidth != 3: props = append(props, fmt.Sprintf("LabelWidth: %d", w.LabelWidth)) } + // A `footer { … }` block already implies ShowFooter: true, so emit the property + // only when the implicit rule would not reproduce the stored value — an empty + // shown footer, or declared footer widgets that are hidden (#813). + if hasFooter := dataViewHasFooterBlock(w); hasFooter != w.ShowFooter { + props = append(props, fmt.Sprintf("ShowFooter: %t", w.ShowFooter)) + } props = appendAppearanceProps(props, w) formatWidgetProps(ctx.Output, prefix, header, props, " {\n") outputDataContainerContext(ctx.Output, prefix+" ", w.Name, w.EntityContext, false) @@ -1496,3 +1502,14 @@ func associationDataSourceExpr(ds *rawDataSource) string { func (e *Executor) outputWidgetMDLV3(w rawWidget, indent int) { outputWidgetMDLV3(e.newExecContext(context.Background()), w, indent) } + +// dataViewHasFooterBlock reports whether describe will emit a `footer { … }` child +// for this DataView, which by itself implies ShowFooter: true on re-exec. +func dataViewHasFooterBlock(w rawWidget) bool { + for _, child := range w.Children { + if child.Type == "Footer" { + return true + } + } + return false +} diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 7f499cb77..e3e390d4a 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -254,6 +254,7 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s widget.EntityContext = inheritedCtx } widget.LabelWidth = extractDataViewLabelWidth(w) + widget.ShowFooter, _ = w["ShowFooter"].(bool) widget.Children = parseDataViewChildren(ctx, w, widget.EntityContext) return []rawWidget{widget} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 8b5b64026..7dc728d71 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/mendixlabs/mxcli/generated/metamodel" "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" @@ -134,8 +135,18 @@ func describeSettings(ctx *ExecContext) error { } fmt.Fprintf(ctx.Output, "alter settings configuration '%s'\n%s;\n\n", cfg.Name, strings.Join(parts, ",\n")) - // Output constant overrides + // Output constant overrides. A private override has no value in the + // model — emitting `value ''` would round-trip into a *shared* empty + // override, moving a value that is deliberately kept off the shared model + // into it. MDL does not author the shared/private choice, so describe + // reports it as a comment instead of a re-executable statement. for _, cv := range cfg.ConstantValues { + if cv.IsPrivate { + fmt.Fprintf(ctx.Output, "-- constant '%s' has a private value in configuration '%s'\n"+ + "-- (stored on the developer's workstation; not part of the shared model)\n\n", + cv.ConstantId, cfg.Name) + continue + } fmt.Fprintf(ctx.Output, "alter settings constant '%s' value '%s'\n in configuration '%s';\n\n", cv.ConstantId, cv.Value, cfg.Name) } @@ -325,7 +336,11 @@ func alterSettingsConfiguration(ctx *ExecContext, ps *model.ProjectSettings, stm valStr := settingsValueToString(val) switch key { case "DatabaseType": - cfg.DatabaseType = valStr + v, err := settingsDatabaseType(key, valStr) + if err != nil { + return err + } + cfg.DatabaseType = v case "DatabaseUrl": cfg.DatabaseUrl = valStr case "DatabaseName": @@ -408,6 +423,18 @@ func alterSettingsConstant(ctx *ExecContext, ps *model.ProjectSettings, stmt *as found := false for _, cv := range cfg.ConstantValues { if cv.ConstantId == stmt.ConstantId { + // Setting a value on a private override would convert it to a shared one, + // publishing into the shared model a value the developer chose to keep on + // their workstation — and breaking their local binding. The shared/private + // choice belongs to the constant, so refuse rather than flip it silently. + if cv.IsPrivate { + return mdlerrors.NewValidationf( + "constant '%s' has a private value in configuration '%s'; "+ + "its value is stored on the developer's workstation, not in the shared model. "+ + "Change the constant to a shared value in Studio Pro first, "+ + "or use `alter settings drop constant '%s' in configuration '%s'` to remove the override", + stmt.ConstantId, targetConfig, stmt.ConstantId, targetConfig) + } cv.Value = stmt.Value found = true break @@ -453,11 +480,15 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er } } + // Mirror the configuration Studio Pro creates: the enum member "Hsqldb" (not + // "HSQLDB", which no metamodel member matches) and both default ports, so a + // fresh configuration is runnable and loads without repair (#759). newCfg := &model.ServerConfiguration{ - Name: stmt.Name, - DatabaseType: "HSQLDB", - HttpPortNumber: 8080, - ConstantValues: []*model.ConstantValue{}, + Name: stmt.Name, + DatabaseType: string(metamodel.SettingsDatabaseTypeHsqldb), + HttpPortNumber: 8080, + ServerPortNumber: 8090, + ConstantValues: []*model.ConstantValue{}, } newCfg.TypeName = "Settings$ServerConfiguration" @@ -466,7 +497,11 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er valStr := settingsValueToString(val) switch key { case "DatabaseType": - newCfg.DatabaseType = valStr + v, err := settingsDatabaseType(key, valStr) + if err != nil { + return err + } + newCfg.DatabaseType = v case "DatabaseUrl": newCfg.DatabaseUrl = valStr case "DatabaseName": @@ -561,6 +596,37 @@ func settingsBool(key, valStr string) (bool, error) { return false, mdlerrors.NewValidationf("%s must be true or false, got %q", key, valStr) } +// databaseTypes lists the members of the Mendix Settings.DatabaseType enumeration +// exactly as Studio Pro spells them in BSON (generated/metamodel.SettingsDatabaseType). +// A configuration stored with anything else — mxcli hardcoded "HSQLDB" for every +// CREATE CONFIGURATION — is a value the metamodel cannot resolve, and Studio Pro +// throws "Sequence contains no matching element" when it loads the configuration +// (mendixlabs/mxcli#759). +var databaseTypes = []string{ + string(metamodel.SettingsDatabaseTypeDb2), + string(metamodel.SettingsDatabaseTypeHsqldb), + string(metamodel.SettingsDatabaseTypeMySql), + string(metamodel.SettingsDatabaseTypeOracle), + string(metamodel.SettingsDatabaseTypePostgreSql), + string(metamodel.SettingsDatabaseTypeSapHana), + string(metamodel.SettingsDatabaseTypeSqlServer), +} + +// settingsDatabaseType canonicalises a DatabaseType value to the enum member +// Mendix stores, matching case-insensitively so 'postgresql' and 'PostgreSql' both +// land on the stored spelling. Anything unrecognised is rejected rather than +// written through. +func settingsDatabaseType(key, valStr string) (string, error) { + want := strings.TrimSpace(valStr) + for _, dt := range databaseTypes { + if strings.EqualFold(dt, want) { + return dt, nil + } + } + return "", mdlerrors.NewValidationf("%s must be one of %s, got %q", + key, strings.Join(databaseTypes, ", "), valStr) +} + // settingsValueToString converts an AST settings value to string. func settingsValueToString(val any) string { switch v := val.(type) { diff --git a/mdl/executor/cmd_settings_databasetype_test.go b/mdl/executor/cmd_settings_databasetype_test.go new file mode 100644 index 000000000..6cf800e74 --- /dev/null +++ b/mdl/executor/cmd_settings_databasetype_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// captureSettingsBackend is settingsBackend with the written document kept, so a +// test can assert on the configuration the handler produced. +func captureSettingsBackend(out **model.ProjectSettings) *mock.MockBackend { + var wrote bool + b := settingsBackend(&wrote) + b.UpdateProjectSettingsFunc = func(ps *model.ProjectSettings) error { + *out = ps + return nil + } + return b +} + +// TestCreateConfiguration_DefaultsMatchStudioPro is the regression test for +// mendixlabs/mxcli#759: CREATE CONFIGURATION hardcoded DatabaseType "HSQLDB", +// which is not a member of the Mendix DatabaseType enumeration ("Hsqldb" is), and +// left ServerPortNumber at 0 so the new configuration had no admin port. +func TestCreateConfiguration_DefaultsMatchStudioPro(t *testing.T) { + var written *model.ProjectSettings + ctx, _ := newMockCtx(t, withBackend(captureSettingsBackend(&written))) + + if err := createConfiguration(ctx, &ast.CreateConfigurationStmt{Name: "Acceptance"}); err != nil { + t.Fatalf("createConfiguration: %v", err) + } + + cfg := configByName(t, written, "Acceptance") + if cfg.DatabaseType != "Hsqldb" { + t.Errorf("DatabaseType = %q, want %q (the enum member Studio Pro stores)", + cfg.DatabaseType, "Hsqldb") + } + if cfg.HttpPortNumber != 8080 { + t.Errorf("HttpPortNumber = %d, want 8080", cfg.HttpPortNumber) + } + if cfg.ServerPortNumber != 8090 { + t.Errorf("ServerPortNumber = %d, want 8090", cfg.ServerPortNumber) + } +} + +// TestCreateConfiguration_CanonicalisesDatabaseType covers the user-supplied half: +// a recognised value is stored in the enum's spelling whatever case it was typed +// in, and an unrecognised one is refused instead of written through. +func TestCreateConfiguration_CanonicalisesDatabaseType(t *testing.T) { + tests := []struct { + given string + want string // "" = must be rejected + }{ + {given: "PostgreSql", want: "PostgreSql"}, + {given: "postgresql", want: "PostgreSql"}, + {given: "SQLSERVER", want: "SqlServer"}, + {given: "hsqldb", want: "Hsqldb"}, + {given: "HSQLDB", want: "Hsqldb"}, + {given: "Postgres", want: ""}, + {given: "", want: ""}, + } + for _, tc := range tests { + t.Run(tc.given, func(t *testing.T) { + var written *model.ProjectSettings + ctx, _ := newMockCtx(t, withBackend(captureSettingsBackend(&written))) + + err := createConfiguration(ctx, &ast.CreateConfigurationStmt{ + Name: "Acceptance", + Properties: map[string]any{"DatabaseType": tc.given}, + }) + if tc.want == "" { + if err == nil { + t.Fatalf("createConfiguration accepted DatabaseType %q", tc.given) + } + if !strings.Contains(err.Error(), "DatabaseType") { + t.Errorf("error does not name the property: %v", err) + } + if written != nil { + t.Error("a rejected CREATE CONFIGURATION still wrote the settings document") + } + return + } + if err != nil { + t.Fatalf("createConfiguration: %v", err) + } + if got := configByName(t, written, "Acceptance").DatabaseType; got != tc.want { + t.Errorf("DatabaseType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestAlterConfiguration_CanonicalisesDatabaseType covers the same on the ALTER +// path, which stored whatever string it was given. +func TestAlterConfiguration_CanonicalisesDatabaseType(t *testing.T) { + var written *model.ProjectSettings + ctx, _ := newMockCtx(t, withBackend(captureSettingsBackend(&written))) + + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "configuration", + ConfigName: "Default", + Properties: map[string]any{"DatabaseType": "oracle"}, + }) + if err != nil { + t.Fatalf("alterSettings: %v", err) + } + if got := configByName(t, written, "Default").DatabaseType; got != "Oracle" { + t.Errorf("DatabaseType = %q, want %q", got, "Oracle") + } + + written = nil + err = alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "configuration", + ConfigName: "Default", + Properties: map[string]any{"DatabaseType": "MongoDB"}, + }) + if err == nil { + t.Fatal("alterSettings accepted DatabaseType \"MongoDB\"") + } + if written != nil { + t.Error("a rejected ALTER still wrote the settings document") + } +} + +func configByName(t *testing.T, ps *model.ProjectSettings, name string) *model.ServerConfiguration { + t.Helper() + if ps == nil || ps.Configuration == nil { + t.Fatal("no settings document written") + } + for _, cfg := range ps.Configuration.Configurations { + if cfg.Name == name { + return cfg + } + } + t.Fatalf("configuration %q not in the written settings", name) + return nil +} diff --git a/mdl/executor/cmd_settings_private_test.go b/mdl/executor/cmd_settings_private_test.go new file mode 100644 index 000000000..8786466d3 --- /dev/null +++ b/mdl/executor/cmd_settings_private_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// privateConstantBackend serves a Default configuration holding one private and one +// shared constant override. +func privateConstantBackend(wrote *bool) *mock.MockBackend { + return &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + ps := &model.ProjectSettings{ + Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{{ + Name: "Default", + ConstantValues: []*model.ConstantValue{ + {ConstantId: "Mod.ApiToken", IsPrivate: true}, + {ConstantId: "Mod.BaseUrl", Value: "https://example.invalid"}, + }, + }}, + }, + } + ps.RawParts = []map[string]any{{"$Type": "Settings$ConfigurationSettings"}} + return ps, nil + }, + UpdateProjectSettingsFunc: func(*model.ProjectSettings) error { + *wrote = true + return nil + }, + } +} + +// TestAlterSettingsConstant_RefusesPrivateOverride: setting a value on a private +// override would convert it to a shared one, publishing into version control a +// value the developer deliberately keeps on their workstation. The shared/private +// choice belongs to the constant, so MDL refuses instead of flipping it. +func TestAlterSettingsConstant_RefusesPrivateOverride(t *testing.T) { + wrote := false + ctx, _ := newMockCtx(t, withBackend(privateConstantBackend(&wrote))) + + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "constant", + ConfigName: "Default", + ConstantId: "Mod.ApiToken", + Value: "leaked-into-git", + }) + if err == nil { + t.Fatal("alterSettings overwrote a private constant override") + } + for _, want := range []string{"Mod.ApiToken", "private", "Studio Pro"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q: %v", want, err) + } + } + if wrote { + t.Error("a refused ALTER still wrote the settings document") + } +} + +// TestAlterSettingsConstant_SharedStillWrites guards the refusal from over-reaching. +func TestAlterSettingsConstant_SharedStillWrites(t *testing.T) { + wrote := false + ctx, _ := newMockCtx(t, withBackend(privateConstantBackend(&wrote))) + + if err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "constant", + ConfigName: "Default", + ConstantId: "Mod.BaseUrl", + Value: "https://staging.invalid", + }); err != nil { + t.Fatalf("alterSettings refused a shared override: %v", err) + } + if !wrote { + t.Error("a valid ALTER did not write the settings document") + } +} + +// TestAlterSettingsConstant_DropPrivateIsAllowed: removing the override entirely +// discards the private/shared choice along with it, which is what the user asked +// for — only converting it in place is refused. +func TestAlterSettingsConstant_DropPrivateIsAllowed(t *testing.T) { + wrote := false + ctx, _ := newMockCtx(t, withBackend(privateConstantBackend(&wrote))) + + if err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "constant", + ConfigName: "Default", + ConstantId: "Mod.ApiToken", + DropConstant: true, + }); err != nil { + t.Fatalf("alterSettings refused to drop a private override: %v", err) + } + if !wrote { + t.Error("DROP CONSTANT did not write the settings document") + } +} + +// TestDescribeSettings_PrivateOverrideIsNotReExecutable: describe emitted +// `value ''` for a private override, so replaying its own output converted the +// override to a shared empty one. +func TestDescribeSettings_PrivateOverrideIsNotReExecutable(t *testing.T) { + wrote := false + ctx, out := newMockCtx(t, withBackend(privateConstantBackend(&wrote))) + + if err := describeSettings(ctx); err != nil { + t.Fatalf("describeSettings: %v", err) + } + got := out.String() + + if strings.Contains(got, "alter settings constant 'Mod.ApiToken'") { + t.Errorf("describe emitted a re-executable statement for a private override:\n%s", got) + } + if !strings.Contains(got, "Mod.ApiToken") || !strings.Contains(got, "private") { + t.Errorf("describe does not report the private override at all:\n%s", got) + } + // The shared override must still round-trip. + if !strings.Contains(got, "alter settings constant 'Mod.BaseUrl' value 'https://example.invalid'") { + t.Errorf("describe dropped the shared override:\n%s", got) + } +} diff --git a/mdl/executor/cmd_settings_validation_test.go b/mdl/executor/cmd_settings_validation_test.go index 2570b7c73..f65c61113 100644 --- a/mdl/executor/cmd_settings_validation_test.go +++ b/mdl/executor/cmd_settings_validation_test.go @@ -385,8 +385,11 @@ func TestTypedSettingsKeys_MatchExecutor(t *testing.T) { // The valid form for this kind must round-trip through both. good := "7" - if kind == settingsKindBool { + switch kind { + case settingsKindBool: good = "true" + case settingsKindDatabaseType: + good = "PostgreSql" } if got := ValidateSettings(&ast.AlterSettingsStmt{ Section: section, diff --git a/mdl/executor/validate_settings.go b/mdl/executor/validate_settings.go index 90f8c5c31..692676b16 100644 --- a/mdl/executor/validate_settings.go +++ b/mdl/executor/validate_settings.go @@ -18,6 +18,7 @@ type settingsValueKind int const ( settingsKindInt settingsValueKind = iota settingsKindBool + settingsKindDatabaseType ) // typedSettingsKeys maps a lower-cased ALTER SETTINGS section to the properties @@ -36,6 +37,7 @@ var typedSettingsKeys = map[string]map[string]settingsValueKind{ "configuration": { "HttpPortNumber": settingsKindInt, "ServerPortNumber": settingsKindInt, + "DatabaseType": settingsKindDatabaseType, }, } @@ -99,6 +101,17 @@ func validateTypedSettings(keys map[string]settingsValueKind, props map[string]a Suggestion: fmt.Sprintf("Use `%s = true` or `%s = false`.", key, key), }) } + case settingsKindDatabaseType: + if _, err := settingsDatabaseType(key, valStr); err != nil { + out = append(out, linter.Violation{ + RuleID: "MDL-SET03", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("%s: %s must be a Mendix database type, got %q", + what, key, valStr), + Suggestion: fmt.Sprintf("Use one of: %s.", strings.Join(databaseTypes, ", ")), + }) + } } } return out diff --git a/mdl/settingsoverlay/private_value_test.go b/mdl/settingsoverlay/private_value_test.go new file mode 100644 index 000000000..6f7774fac --- /dev/null +++ b/mdl/settingsoverlay/private_value_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package settingsoverlay + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/model" +) + +// privateOverride is the shape Studio Pro stores for a constant override whose +// value is private: a Settings$PrivateValue marker with no properties, because the +// value lives on the developer's workstation rather than in the shared model. +func privateOverride() bson.A { + return bson.A{ + int32(3), + bson.M{ + "$ID": "cv-id", + "$Type": "Settings$ConstantValue", + "ConstantId": "Mod.ApiToken", + "SharedOrPrivateValue": bson.M{ + "$ID": "spv-id", + "$Type": PrivateValueType, + }, + }, + } +} + +// TestConstantValues_PreservesPrivateValue: the overlay assumed every stored +// SharedOrPrivateValue was a SharedValue and wrote cv.Value into it. For a private +// override cv.Value is always "" (the value is not in the model), so the write both +// fabricated a "Value" property on a type that defines none — the #759 +// "Sequence contains no matching element" shape — and reported the override as +// empty. Configurations must respect the constant's private/shared choice, so the +// node is preserved exactly as stored. +func TestConstantValues_PreservesPrivateValue(t *testing.T) { + cvs := ArrayElements(ConstantValues( + []*model.ConstantValue{{ConstantId: "Mod.ApiToken", IsPrivate: true}}, + privateOverride(), + )) + if len(cvs) != 1 { + t.Fatalf("got %d overrides, want 1", len(cvs)) + } + spv, ok := AsMap(cvs[0]["SharedOrPrivateValue"]) + if !ok { + t.Fatalf("SharedOrPrivateValue dropped: %#v", cvs[0]) + } + if spv["$Type"] != PrivateValueType { + t.Errorf("$Type = %#v, want %q — the private marker was replaced", spv["$Type"], PrivateValueType) + } + if v, has := spv["Value"]; has { + t.Errorf("wrote Value = %#v onto a %s, which defines no properties", v, PrivateValueType) + } + if len(spv) != 2 { + t.Errorf("private marker gained properties: %#v", spv) + } +} + +// TestConstantValues_PreservesPrivateValue_UnrelatedWrite is the realistic path: +// nothing about the constant is being edited, the settings document is merely +// rewritten because some other property changed. Every ALTER SETTINGS and CREATE +// CONFIGURATION goes through this, and configurations are shared in version +// control — so one developer's unrelated edit corrupted every developer's private +// overrides. +func TestConstantValues_PreservesPrivateValue_UnrelatedWrite(t *testing.T) { + part := map[string]any{ + "Configurations": bson.A{ + int32(3), + bson.M{ + "$ID": "id-default", + "Name": "Default", + "ConstantValues": privateOverride(), + }, + }, + } + cs := &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{{ + Name: "Default", + HttpPortNumber: 8099, // the only thing actually being changed + ConstantValues: []*model.ConstantValue{{ConstantId: "Mod.ApiToken", IsPrivate: true}}, + }}, + } + + cfgs := ArrayElements(Configurations(cs, part)["Configurations"]) + spv, ok := AsMap(ArrayElements(cfgs[0]["ConstantValues"])[0]["SharedOrPrivateValue"]) + if !ok { + t.Fatalf("SharedOrPrivateValue dropped: %#v", cfgs[0]) + } + if _, has := spv["Value"]; has { + t.Errorf("an unrelated port change corrupted the private override: %#v", spv) + } +} + +// TestConstantValues_SharedStillUpdates guards the fix from over-reaching: a +// shared override must still be written in place. +func TestConstantValues_SharedStillUpdates(t *testing.T) { + raw := bson.A{ + int32(3), + bson.M{ + "$ID": "cv-id", + "$Type": "Settings$ConstantValue", + "ConstantId": "Mod.C1", + "SharedOrPrivateValue": bson.M{ + "$Type": "Settings$SharedValue", + "Value": "old", + }, + }, + } + cvs := ArrayElements(ConstantValues([]*model.ConstantValue{{ConstantId: "Mod.C1", Value: "new"}}, raw)) + spv, _ := AsMap(cvs[0]["SharedOrPrivateValue"]) + if spv["Value"] != "new" { + t.Errorf("shared override not updated: %#v", spv) + } +} diff --git a/mdl/settingsoverlay/settingsoverlay.go b/mdl/settingsoverlay/settingsoverlay.go index c993cdc5f..a3a7c52a1 100644 --- a/mdl/settingsoverlay/settingsoverlay.go +++ b/mdl/settingsoverlay/settingsoverlay.go @@ -31,6 +31,13 @@ import ( // a hardcoded marker silently downgrades it. const DefaultListMarker = int32(3) +// PrivateValueType is the BSON $Type Studio Pro nests in a Settings$ConstantValue +// when the override's value is private: kept on the developer's workstation instead +// of in the shared model, so the model stores only this marker. Unlike +// Settings$SharedValue it defines no properties at all — writing one into it is the +// mendixlabs/mxcli#759 failure shape. +const PrivateValueType = "Settings$PrivateValue" + // SafeInt64 converts an int to int64 with a guard against the float64 safe-integer // range (settings values are tiny, but keep the conversion bounds-checked). func SafeInt64(v int) int64 { @@ -100,10 +107,14 @@ func ServerConfiguration(cfg *model.ServerConfiguration, raw map[string]any, sib // counterpart on disk. A sibling configuration is the shape template when one // exists — it carries the exact field set and list markers this project's Mendix // version writes — with the per-configuration collections emptied and the $ID -// dropped so a fresh one is minted. Tracing is inherited from the sibling -// deliberately: its shape is version-specific and a new configuration has no better -// default to offer. Falls back to the field set observed in Studio Pro 10/11 -// projects when the project has no configuration at all. +// dropped so a fresh one is minted. The tracing property is inherited from the +// sibling deliberately: it is version-specific (Mendix 11.6 stores "Tracing", 11.12 +// "OpenTelemetry") and a new configuration has no better default to offer. +// +// With no sibling to copy there is no way to know which spelling this version +// expects, so the fallback writes neither: a property the metamodel does not define +// is what Studio Pro chokes on (see JavaVersionKey and mendixlabs/mxcli#759), while +// an absent optional property is filled in on load. func newServerConfiguration(cfg *model.ServerConfiguration, siblings []map[string]any) map[string]any { if len(siblings) > 0 { tmpl := make(map[string]any, len(siblings[0])) @@ -118,12 +129,50 @@ func newServerConfiguration(cfg *model.ServerConfiguration, siblings []map[strin return map[string]any{ "ConstantValues": bson.A{DefaultListMarker}, "CustomSettings": bson.A{DefaultListMarker}, - "Tracing": nil, "OpenAdminPort": cfg.OpenAdminPort, "OpenHttpPort": cfg.OpenHttpPort, } } +// JavaVersionKey returns the storage key a Settings$ModelSettings part uses for +// the runtime Java version, or "" when it carries neither. +// +// Mendix renamed the property between 11.6 ("JavaVersion", values like "Java21") +// and 11.12 ("JavaMajorVersion", values like "21"). Writing a hardcoded +// "JavaVersion" onto an 11.12 document therefore left the real JavaMajorVersion +// stale and added a property that version's metamodel does not define — Studio Pro +// resolves each stored property against the type's property list and threw +// "Sequence contains no matching element" on the next open +// (mendixlabs/mxcli#759). Read the key off the document instead of assuming one, +// and never invent a key the document does not already have. +func JavaVersionKey(raw map[string]any) string { + for _, k := range []string{"JavaMajorVersion", "JavaVersion"} { + if _, ok := raw[k]; ok { + return k + } + } + return "" +} + +// JavaVersion reads the runtime Java version from a raw Settings$ModelSettings +// part under whichever key this Mendix version stores it. +func JavaVersion(raw map[string]any) string { + k := JavaVersionKey(raw) + if k == "" { + return "" + } + v, _ := raw[k].(string) + return v +} + +// SetJavaVersion writes the runtime Java version back to the key it was read from. +// A part carrying neither key is left untouched. +func SetJavaVersion(raw map[string]any, v string) { + if k := JavaVersionKey(raw); k != "" { + raw[k] = v + } +} + // ConstantValues rebuilds a configuration's ConstantValues list, updating each // override in the slot it is already stored in so its value shape survives. // Studio Pro and mxbuild only read the nested SharedOrPrivateValue; a flat "Value" @@ -167,6 +216,16 @@ func constantValue(cv *model.ConstantValue, raw map[string]any) map[string]any { } } if shared, ok := AsMap(raw["SharedOrPrivateValue"]); ok { + // A private override has no value in the model — Settings$PrivateValue is a + // marker type with no properties, because the value lives on the developer's + // workstation. Writing cv.Value (always "") into it would both fabricate a + // property Mendix cannot resolve — the mendixlabs/mxcli#759 failure shape, + // "Sequence contains no matching element" at MprProperty — and misreport the + // override as empty. The shared/private choice belongs to the constant, not + // to a configuration edit: preserve the node exactly as stored. + if shared["$Type"] == PrivateValueType { + return raw + } shared["Value"] = cv.Value raw["SharedOrPrivateValue"] = shared // Clear a flat sibling rather than leave the two disagreeing: the reader diff --git a/mdl/settingsoverlay/settingsoverlay_test.go b/mdl/settingsoverlay/settingsoverlay_test.go index 248fa4630..eef02b4d9 100644 --- a/mdl/settingsoverlay/settingsoverlay_test.go +++ b/mdl/settingsoverlay/settingsoverlay_test.go @@ -275,8 +275,15 @@ func TestServerConfiguration_NoSiblings(t *testing.T) { t.Errorf("fallback configuration is missing %q: %#v", key, got) } } - if _, ok := got["Tracing"]; !ok { - t.Errorf("fallback configuration is missing Tracing: %#v", got) + // The tracing property is version-specific — Mendix 11.6 stores "Tracing", + // 11.12 "OpenTelemetry" — and with no sibling to copy there is nothing to tell + // the two apart. Writing either spelling risks a property the version's + // metamodel does not define, which is what Studio Pro fails to resolve + // (mendixlabs/mxcli#759); an absent optional property is filled in on load. + for _, key := range []string{"Tracing", "OpenTelemetry"} { + if v, ok := got[key]; ok { + t.Errorf("fallback configuration invented %q = %#v", key, v) + } } if m := ArrayMarker(got["CustomSettings"], -1); m != DefaultListMarker { t.Errorf("CustomSettings marker = %d, want %d", m, DefaultListMarker) @@ -295,3 +302,66 @@ func TestSafeInt64_Bounds(t *testing.T) { t.Errorf("SafeInt64 below range = %d, want clamp to %d", got, -maxSafe) } } + +// TestJavaVersionKey_FollowsDocument covers mendixlabs/mxcli#759: Mendix renamed +// the runtime Java version property between 11.6 ("JavaVersion" = "Java21") and +// 11.12 ("JavaMajorVersion" = "21"). The overlay must write back to the key the +// document already carries and invent neither, because a property the version's +// metamodel does not define is what Studio Pro fails to resolve on open. +func TestJavaVersionKey_FollowsDocument(t *testing.T) { + tests := []struct { + name string + raw map[string]any + wantKey string + wantVal string + }{ + { + name: "mendix 11.6", + raw: map[string]any{"JavaVersion": "Java21"}, + wantKey: "JavaVersion", + wantVal: "Java21", + }, + { + name: "mendix 11.12", + raw: map[string]any{"JavaMajorVersion": "21"}, + wantKey: "JavaMajorVersion", + wantVal: "21", + }, + { + name: "neither key", + raw: map[string]any{"HashAlgorithm": "BCrypt"}, + wantKey: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := JavaVersionKey(tc.raw); got != tc.wantKey { + t.Fatalf("JavaVersionKey = %q, want %q", got, tc.wantKey) + } + if got := JavaVersion(tc.raw); got != tc.wantVal { + t.Errorf("JavaVersion = %q, want %q", got, tc.wantVal) + } + + SetJavaVersion(tc.raw, "written") + if tc.wantKey == "" { + for _, k := range []string{"JavaVersion", "JavaMajorVersion"} { + if v, ok := tc.raw[k]; ok { + t.Errorf("SetJavaVersion invented %q = %#v", k, v) + } + } + return + } + if tc.raw[tc.wantKey] != "written" { + t.Errorf("%s = %#v, want %q", tc.wantKey, tc.raw[tc.wantKey], "written") + } + for _, k := range []string{"JavaVersion", "JavaMajorVersion"} { + if k == tc.wantKey { + continue + } + if v, ok := tc.raw[k]; ok { + t.Errorf("SetJavaVersion also wrote %q = %#v", k, v) + } + } + }) + } +} diff --git a/model/types.go b/model/types.go index c93740e7f..23373e324 100644 --- a/model/types.go +++ b/model/types.go @@ -850,7 +850,14 @@ type ServerConfiguration struct { type ConstantValue struct { BaseElement ConstantId string `json:"constantId"` // Qualified name: "BusinessEvents.ServerUrl" - Value string `json:"value"` // The overridden value + Value string `json:"value"` // The overridden value (empty when IsPrivate) + // IsPrivate marks an override whose value is private: the stored + // SharedOrPrivateValue is a Settings$PrivateValue, a marker type with no + // properties, because the value lives on the developer's workstation and is + // deliberately kept out of the shared model. Value is therefore always empty + // here — "" means "not in the model", not "overridden with the empty string". + // mxcli preserves the choice and never authors it. + IsPrivate bool `json:"isPrivate,omitempty"` } // ModelSettings represents Settings$ModelSettings. diff --git a/sdk/mpr/parser_settings.go b/sdk/mpr/parser_settings.go index ec2fc2c09..1af1f7240 100644 --- a/sdk/mpr/parser_settings.go +++ b/sdk/mpr/parser_settings.go @@ -5,6 +5,7 @@ package mpr import ( "fmt" + "github.com/mendixlabs/mxcli/mdl/settingsoverlay" "github.com/mendixlabs/mxcli/model" "go.mongodb.org/mongo-driver/bson" @@ -131,9 +132,15 @@ func parseConstantValue(raw map[string]any) *model.ConstantValue { cv.TypeName = extractString(raw["$Type"]) cv.ConstantId = extractString(raw["ConstantId"]) - // Value is nested in SharedOrPrivateValue → Value + // Value is nested in SharedOrPrivateValue → Value. A Settings$PrivateValue + // carries no value at all: it marks an override whose value lives on the + // developer's workstation, outside the shared model. if spv := extractBsonMap(raw["SharedOrPrivateValue"]); spv != nil { - cv.Value = extractString(spv["Value"]) + if extractString(spv["$Type"]) == settingsoverlay.PrivateValueType { + cv.IsPrivate = true + } else { + cv.Value = extractString(spv["Value"]) + } } return cv @@ -149,7 +156,7 @@ func parseModelSettings(raw map[string]any) *model.ModelSettings { ms.AllowUserMultipleSessions = extractBool(raw["AllowUserMultipleSessions"], true) ms.HashAlgorithm = extractString(raw["HashAlgorithm"]) ms.BcryptCost = extractInt(raw["BcryptCost"]) - ms.JavaVersion = extractString(raw["JavaVersion"]) + ms.JavaVersion = settingsoverlay.JavaVersion(raw) ms.RoundingMode = extractString(raw["RoundingMode"]) ms.ScheduledEventTimeZoneCode = extractString(raw["ScheduledEventTimeZoneCode"]) ms.FirstDayOfWeek = extractString(raw["FirstDayOfWeek"]) diff --git a/sdk/mpr/writer_pages.go b/sdk/mpr/writer_pages.go index 179810072..c877d29af 100644 --- a/sdk/mpr/writer_pages.go +++ b/sdk/mpr/writer_pages.go @@ -162,8 +162,8 @@ func (w *Writer) serializePage(page *pages.Page) ([]byte, error) { {Key: "Parameter", Value: string(arg.ParameterID)}, // Qualified name string } // Add widgets if present - if arg.Widget != nil { - argDoc = append(argDoc, bson.E{Key: "Widgets", Value: serializeWidgetArray([]pages.Widget{arg.Widget})}) + if len(arg.Widgets) > 0 { + argDoc = append(argDoc, bson.E{Key: "Widgets", Value: serializeWidgetArray(arg.Widgets)}) } else { argDoc = append(argDoc, bson.E{Key: "Widgets", Value: bson.A{int32(3)}}) } diff --git a/sdk/mpr/writer_pages_placeholder_test.go b/sdk/mpr/writer_pages_placeholder_test.go new file mode 100644 index 000000000..07e903058 --- /dev/null +++ b/sdk/mpr/writer_pages_placeholder_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#760: every mxcli-authored page gained a container nobody asked +// for. The builder wrapped each non-empty layout placeholder in a synthetic +// Forms$DivContainer named "conditionalVisibilityWidget", so creating a single +// button produced a button *and* a container. +// +// The wrapper was never a BSON requirement. Forms$FormCallArgument carries a +// `Widgets` array and a Studio Pro page fills it with its top-level widgets +// directly — verified against Mendix's own output: Administration.Account_Overview in +// a `mx create-project` app has two top-level widgets in one placeholder and zero +// wrappers. The wrapper existed only because pages.LayoutCallArgument declared a +// single `Widget` field. +package mpr + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func placeholderArg(widgets ...pages.Widget) *pages.Page { + return &pages.Page{ + BaseElement: model.BaseElement{ID: "page1"}, + Name: "P", + LayoutCall: &pages.LayoutCall{ + BaseElement: model.BaseElement{ID: "lc1"}, + LayoutName: "Atlas_Core.Atlas_Default", + Arguments: []*pages.LayoutCallArgument{{ + BaseElement: model.BaseElement{ID: "arg1"}, + ParameterID: model.ID("Atlas_Core.Atlas_Default.Main"), + Widgets: widgets, + }}, + }, + } +} + +func argWidgets(t *testing.T, page *pages.Page) primitive.A { + t.Helper() + w := &Writer{} + raw, err := w.serializePage(page) + if err != nil { + t.Fatalf("serializePage: %v", err) + } + var m map[string]any + if err := bson.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + fc := toMap(m["FormCall"]) + if fc == nil { + t.Fatal("FormCall missing") + } + args, _ := fc["Arguments"].(primitive.A) + if len(args) < 2 { + t.Fatalf("Arguments = %v, want a marker plus one argument", args) + } + arg := toMap(args[1]) + ws, _ := arg["Widgets"].(primitive.A) + return ws +} + +func btn(name string) *pages.ActionButton { + return &pages.ActionButton{BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ID: model.ID(name), TypeName: "Forms$ActionButton"}, + Name: name, + }} +} + +// TestLayoutPlaceholder_WidgetsSerializedDirectly is the regression: whatever widgets +// a placeholder holds must reach BSON as-is, with no synthetic container inserted. +func TestLayoutPlaceholder_WidgetsSerializedDirectly(t *testing.T) { + tests := []struct { + name string + widgets []pages.Widget + want int + }{ + {"single widget", []pages.Widget{btn("b1")}, 1}, + // The case the wrapper was introduced for: the array holds them side by side. + {"several widgets", []pages.Widget{btn("b1"), btn("b2"), btn("b3")}, 3}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ws := argWidgets(t, placeholderArg(tc.widgets...)) + // First element is the array version marker. + if got := len(ws) - 1; got != tc.want { + t.Fatalf("placeholder holds %d widget(s), want %d — a wrapper would collapse them to 1 (#760)", got, tc.want) + } + for _, w := range ws[1:] { + m := toMap(w) + if m == nil { + continue + } + if ty := extractString(m["$Type"]); ty == "Forms$DivContainer" { + t.Errorf("a synthetic DivContainer wrapper is back (#760): %v", m["Name"]) + } + } + }) + } +} + +// An empty placeholder must still emit the empty Widgets array Mendix expects. +func TestLayoutPlaceholder_EmptyStillEmitsWidgets(t *testing.T) { + ws := argWidgets(t, placeholderArg()) + if len(ws) != 1 { + t.Fatalf("empty placeholder Widgets = %v, want just the array marker", ws) + } +} diff --git a/sdk/mpr/writer_settings.go b/sdk/mpr/writer_settings.go index 7c5e442f1..ad1529244 100644 --- a/sdk/mpr/writer_settings.go +++ b/sdk/mpr/writer_settings.go @@ -94,7 +94,7 @@ func serializeModelSettings(ms *model.ModelSettings, raw map[string]any) map[str raw["AllowUserMultipleSessions"] = ms.AllowUserMultipleSessions raw["HashAlgorithm"] = ms.HashAlgorithm raw["BcryptCost"] = safeInt64(ms.BcryptCost) - raw["JavaVersion"] = ms.JavaVersion + settingsoverlay.SetJavaVersion(raw, ms.JavaVersion) raw["RoundingMode"] = ms.RoundingMode raw["ScheduledEventTimeZoneCode"] = ms.ScheduledEventTimeZoneCode raw["FirstDayOfWeek"] = ms.FirstDayOfWeek diff --git a/sdk/mpr/writer_widgets_display.go b/sdk/mpr/writer_widgets_display.go index ee83ff016..22b2dd7d0 100644 --- a/sdk/mpr/writer_widgets_display.go +++ b/sdk/mpr/writer_widgets_display.go @@ -442,17 +442,13 @@ func serializeTitle(t *pages.Title) bson.D { return doc } -// dataViewLabelWidth resolves the LabelWidth to write to BSON. LabelWidth wins -// if explicitly set; otherwise FormOrientation is the source (Vertical -> 0, -// Horizontal/unset -> Mendix's metamodel default of 3). +// dataViewLabelWidth resolves the LabelWidth to write to BSON. The rule lives on +// the model (pages.DataView.ResolvedLabelWidth) so this writer and the modelsdk one +// cannot drift — only this one used to translate FormOrientation, which is how +// `FormOrientation: Vertical` came to be silently dropped on the default engine +// (mendixlabs/mxcli#762). func dataViewLabelWidth(dv *pages.DataView) int64 { - if dv.LabelWidth != nil { - return int64(*dv.LabelWidth) - } - if dv.FormOrientation == pages.FormOrientationVertical { - return 0 - } - return 3 + return int64(dv.ResolvedLabelWidth()) } // serializeDataView serializes a DataView widget with all required properties. diff --git a/sdk/pages/dataview_labelwidth_test.go b/sdk/pages/dataview_labelwidth_test.go new file mode 100644 index 000000000..6bc2e85d7 --- /dev/null +++ b/sdk/pages/dataview_labelwidth_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#762: `dataview dv (… , FormOrientation: Vertical)` had no effect +// on the default (modelsdk) engine. Studio Pro's "Form orientation" radio is stored +// as LabelWidth in BSON — Vertical is 0, Horizontal is Mendix's default of 3 — and +// only the legacy writer performed that translation. The modelsdk writer emitted +// LabelWidth solely when an explicit `LabelWidth:` was given, so FormOrientation was +// read into the model and then dropped. +// +// The resolution now lives on the model, so both writers share one definition of the +// mapping instead of one of them owning it. +package pages + +import "testing" + +func TestResolvedLabelWidth(t *testing.T) { + lw := func(n int) *int { return &n } + + tests := []struct { + name string + orientation FormOrientation + labelWidth *int + want int + }{ + {"unset is Mendix's default", "", nil, 3}, + {"horizontal is the default", FormOrientationHorizontal, nil, 3}, + {"vertical puts the label above", FormOrientationVertical, nil, 0}, + // An explicit LabelWidth is the more specific statement and wins, which is + // what the documented `LabelWidth: 0` ⇔ `FormOrientation: Vertical` note means. + {"explicit LabelWidth wins over orientation", FormOrientationVertical, lw(4), 4}, + {"explicit LabelWidth wins over horizontal", FormOrientationHorizontal, lw(0), 0}, + {"explicit LabelWidth alone", "", lw(6), 6}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dv := &DataView{FormOrientation: tc.orientation, LabelWidth: tc.labelWidth} + if got := dv.ResolvedLabelWidth(); got != tc.want { + t.Errorf("ResolvedLabelWidth() = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/sdk/pages/pages_parameters.go b/sdk/pages/pages_parameters.go index 1489890cd..05466abfb 100644 --- a/sdk/pages/pages_parameters.go +++ b/sdk/pages/pages_parameters.go @@ -14,11 +14,20 @@ type LayoutCall struct { Arguments []*LayoutCallArgument `json:"arguments,omitempty"` } -// LayoutCallArgument represents an argument binding in a layout call. +// LayoutCallArgument represents an argument binding in a layout call — the widgets +// placed into one of the layout's placeholders. +// +// Widgets is a list because the BSON is: Forms$FormCallArgument carries a `Widgets` +// array, and a Studio Pro page puts its top-level widgets in it directly. This used +// to be a single Widget, which forced the builder to wrap every non-empty +// placeholder in a synthetic Forms$DivContainer named "conditionalVisibilityWidget" +// just to squeeze N widgets through a 1-widget field. That container appeared in the +// widget tree of every mxcli-authored page and was never something the author asked +// for (mendixlabs/mxcli#760). type LayoutCallArgument struct { model.BaseElement ParameterID model.ID `json:"parameterId"` - Widget Widget `json:"widget,omitempty"` + Widgets []Widget `json:"widgets,omitempty"` } // PageParameter represents a parameter of a page. diff --git a/sdk/pages/pages_widgets_data.go b/sdk/pages/pages_widgets_data.go index 08535b8dc..070c76710 100644 --- a/sdk/pages/pages_widgets_data.go +++ b/sdk/pages/pages_widgets_data.go @@ -30,6 +30,30 @@ const ( FormOrientationVertical FormOrientation = "Vertical" ) +// DefaultLabelWidth is Mendix's metamodel default for DataView.LabelWidth — the +// Horizontal form orientation, label beside the input taking 3/12 of the row. +const DefaultLabelWidth = 3 + +// ResolvedLabelWidth is the LabelWidth a writer must emit for this DataView. +// +// Studio Pro's "Form orientation" radio has no BSON field of its own: it *is* +// LabelWidth (0 = Vertical, >0 = Horizontal). An explicit LabelWidth is the more +// specific statement and therefore wins over FormOrientation. +// +// This lives on the model so both writers share one definition. Previously only the +// legacy writer performed the translation and the modelsdk one emitted LabelWidth +// only when it was set explicitly, so `FormOrientation: Vertical` was parsed into the +// model and then silently dropped on the default engine (mendixlabs/mxcli#762). +func (dv *DataView) ResolvedLabelWidth() int { + if dv.LabelWidth != nil { + return *dv.LabelWidth + } + if dv.FormOrientation == FormOrientationVertical { + return 0 + } + return DefaultLabelWidth +} + // ListView represents a list view widget. type ListView struct { BaseWidget