Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Pages still bound to the layout are **named in a warning and the drop proceeds**, matching every other DROP in mxcli (none refuse on dependents) and matching how a layout is normally corrected: re-create it under the same name and the pages rebind by qualified name — verified end to end, back to 0 errors. Left dropped, each page fails **CE1613** "The selected layout … no longer exists", which names the *page* and never the layout, so the warning names the pages and gives the `alter pages … where layout =` repoint.

- **`run --local --db-type hsqldb` — boot with no database server** — a local run can now use Mendix's built-in, file-based HSQLDB instead of PostgreSQL. The runtime already ships the driver and derives the file URL from `DatabaseType` and `DatabaseName`, so the data lands under `<project>/deployment/data/database/hsqldb/`. `--db-host`, `--db-user` and `--db-password` are refused with HSQLDB (it is a file, not a connection) and `--ensure-db` is refused too (there is nothing to provision); PostgreSQL remains the default and is unchanged. Intended for local development and demos only — Mendix marks HSQLDB as local-testing-only.

### Fixed

- **The runtime start cycle did not create a database that does not exist yet** — `RuntimeController.Start` only ran `execute_ddl_commands` when Mendix answered result 3 ("the database has to be updated"). A brand-new database answers result 2 ("the database to be used does not exist"), which was unhandled, so the first boot against the built-in HSQLDB database failed with `start failed: The database to be used does not exist.` Both results now trigger the schema step, and a start that still fails after it says so explicitly.

- **`mxcli run --local` hung forever on Windows at `Starting mxbuild --serve...`** — two POSIX assumptions in `cmd/mxcli/docker` combined. `ServeServer.alive()` (and `LocalRuntime.alive()`) asked `os.Process.Signal(syscall.Signal(0))`, which Go implements as `EWINDOWS` ("not supported by windows") for every signal but `Kill` — so a live mxbuild read as dead and `waitReady()` aborted the boot the instant it launched it. And `Stop()` waited on `cmd.Wait()` after a `killProcessGroup` that on Windows only called `p.Kill()`: mxbuild.exe is a wrapper that launches a Deno web-ext worker (`modeler/tools/deno/win-x64/deno.exe`) which inherits the stdout/stderr pipe `exec.Cmd` hands out, so the orphan kept the pipe open and `Wait()` never saw EOF — no error, no exit, just a hang. Windows now has a real liveness check (`OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`) and a tree kill (`taskkill /F /T`) behind `signalProcessGroup`/`killProcessGroup`; the POSIX implementations are unchanged. The Windows-only tests in `procgroup_windows_test.go` pin both halves — the tree-kill test fails on the pre-fix code, verified as a control — and a `windows-latest` CI job runs them (asserting they actually executed) so neither can come back.

- **`raise error;` on a microflow's main flow passed check and exec, then failed the build** (mendixlabs/mxcli#1030) — with `[error] [CE0710] "The main flow cannot join an error flow or end in an error event."`, one per microflow. Mendix's error event *re-raises the error being handled*, so it is legal only where an error is in scope: inside an `on error { … }` handler. Studio Pro will not draw the connection from the normal flow to an error event; mxcli could, and did. It is now **MDL084**, at error severity, so `exec`'s pre-flight refuses the script with nothing written (`--no-check` still applies it, for reproducing the build failure).
Expand Down
6 changes: 6 additions & 0 deletions cmd/mxcli/cmd_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Requirements:
- A reachable PostgreSQL (the devcontainer provides one); the database must
already exist. Defaults: 127.0.0.1:5432, user 'mendix', db from the project
name. Override with --db-host/--db-name/--db-user/--db-password.
- --db-type hsqldb runs against the built-in file database instead of
PostgreSQL, so no database server is needed. Its data lives under the
project's deployment directory and is meant for local development only.

With --hub, the running app is exposed in a browser at a public URL through an
mxcli tunnel-hub, without leaving this machine: a tunnel client reverse-tunnels
Expand Down Expand Up @@ -147,6 +150,7 @@ Examples:
adminPort, _ := cmd.Flags().GetInt("admin-port")
servePort, _ := cmd.Flags().GetInt("serve-port")
mxbuildPath, _ := cmd.Flags().GetString("mxbuild-path")
dbType, _ := cmd.Flags().GetString("db-type")
dbHost, _ := cmd.Flags().GetString("db-host")
dbName, _ := cmd.Flags().GetString("db-name")
dbUser, _ := cmd.Flags().GetString("db-user")
Expand Down Expand Up @@ -219,6 +223,7 @@ Examples:
TraceService: traceService,
TraceOTLP: traceOTLP,
DB: docker.DBConfig{
Type: dbType,
Host: dbHost,
Name: dbName,
User: dbUser,
Expand Down Expand Up @@ -313,6 +318,7 @@ func init() {
runCmd.Flags().Int("admin-port", 0, "M2EE admin API port (default 8090)")
runCmd.Flags().Int("serve-port", 0, "mxbuild --serve port (default 6543)")
runCmd.Flags().String("mxbuild-path", "", "Path to the mxbuild to build with, overriding resolution (Studio Pro's bundled mxbuild on macOS/Windows, the cached CDN download on Linux)")
runCmd.Flags().String("db-type", "", "Database type for a local run: postgresql (default) or hsqldb (the runtime's built-in file database — no server, no --db-host/--db-user/--db-password, data under <project>/deployment/data/database/hsqldb/)")
runCmd.Flags().String("db-host", "", "Database host:port (IPv6: [::1]:5432; default 127.0.0.1:5432)")
runCmd.Flags().String("db-name", "", "Database name (default derived from the project name)")
runCmd.Flags().String("db-user", "", "Database user (default mendix)")
Expand Down
45 changes: 45 additions & 0 deletions cmd/mxcli/docker/dbtype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: Apache-2.0

package docker

import (
"fmt"
"strings"
)

// Canonical database types a local run understands. The runtime's own enum also
// has Db2/MySql/Oracle/SapHana/SqlServer; those all need a server and are out of
// scope for `run --local`.
const (
DBTypePostgreSQL = "postgresql"
DBTypeHSQLDB = "hsqldb"
)

// NormalizeDBType maps a user-supplied --db-type to a canonical value. Empty
// means PostgreSQL, which is what a local run has always used, so the flag stays
// backwards compatible.
func NormalizeDBType(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", DBTypePostgreSQL, "postgres":
return DBTypePostgreSQL, nil
case DBTypeHSQLDB:
return DBTypeHSQLDB, nil
default:
return "", fmt.Errorf("unknown --db-type %q (want postgresql or hsqldb)", raw)
}
}

// IsFileBasedDBType reports whether a canonical type is the runtime's built-in
// file database, which has no host to reach and no credentials.
func IsFileBasedDBType(canonical string) bool {
return canonical == DBTypeHSQLDB
}

// RuntimeDatabaseType maps a canonical type to the spelling the runtime's
// DatabaseType parameter expects (the runtime enum is upper case).
func RuntimeDatabaseType(canonical string) string {
if canonical == DBTypeHSQLDB {
return "HSQLDB"
}
return "PostgreSQL"
}
43 changes: 43 additions & 0 deletions cmd/mxcli/docker/dbtype_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: Apache-2.0

package docker

import "testing"

func TestNormalizeDBType(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{"", DBTypePostgreSQL, false},
{"postgresql", DBTypePostgreSQL, false},
{"PostgreSQL", DBTypePostgreSQL, false},
{"postgres", DBTypePostgreSQL, false},
{"hsqldb", DBTypeHSQLDB, false},
{"HSQLDB", DBTypeHSQLDB, false},
{" Hsqldb ", DBTypeHSQLDB, false},
{"mysql", "", true},
}
for _, c := range cases {
got, err := NormalizeDBType(c.in)
if c.wantErr {
if err == nil {
t.Errorf("NormalizeDBType(%q) = %q, want error", c.in, got)
}
continue
}
if err != nil || got != c.want {
t.Errorf("NormalizeDBType(%q) = %q, %v; want %q", c.in, got, err, c.want)
}
}
}

func TestRuntimeDatabaseType(t *testing.T) {
if got := RuntimeDatabaseType(DBTypeHSQLDB); got != "HSQLDB" {
t.Errorf("RuntimeDatabaseType(hsqldb) = %q, want HSQLDB", got)
}
if got := RuntimeDatabaseType(DBTypePostgreSQL); got != "PostgreSQL" {
t.Errorf("RuntimeDatabaseType(postgresql) = %q, want PostgreSQL", got)
}
}
11 changes: 10 additions & 1 deletion cmd/mxcli/docker/localboot.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import (
// already resolved, to <deployDir>/model/config.json — readDeploymentConstants
// lifts them from there.

// DBConfig is the external Postgres the standalone runtime connects to.
// DBConfig is the database the standalone runtime connects to: either an external
// Postgres (Host/User/Password set) or the runtime's built-in file database
// (Type "HSQLDB", Host/User/Password empty — see applyDatabaseDefaults).
type DBConfig struct {
Type string // e.g. "PostgreSQL"
Host string // "host:port", e.g. "127.0.0.1:5432"
Expand All @@ -42,6 +44,13 @@ type DBConfig struct {
Password string
}

// IsFileBased reports whether this is the runtime's built-in file database, which
// has no host to reach. It keys on the runtime spelling that applyDatabaseDefaults
// sets (RuntimeDatabaseType), not the raw --db-type flag.
func (c DBConfig) IsFileBased() bool {
return c.Type == RuntimeDatabaseType(DBTypeHSQLDB)
}

// LocalRuntimeOptions configures StartLocalRuntime.
type LocalRuntimeOptions struct {
// DeployDir is the deployment directory (the runtime's BasePath). The mxbuild
Expand Down
18 changes: 18 additions & 0 deletions cmd/mxcli/docker/localboot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,24 @@ func TestRuntimeConfigParams_ApplicationRootUrl(t *testing.T) {
}
}

func TestRuntimeConfigParams_HSQLDB(t *testing.T) {
o := testLocalOpts()
o.DB = DBConfig{Type: "HSQLDB", Name: "appdb"}
p := runtimeConfigParams(o, nil)
checks := map[string]any{
"DatabaseType": "HSQLDB",
"DatabaseHost": "",
"DatabaseName": "appdb",
"DatabaseUserName": "",
"DatabasePassword": "",
}
for k, want := range checks {
if p[k] != want {
t.Errorf("%s = %v, want %v", k, p[k], want)
}
}
}

func TestReadDeploymentConstants(t *testing.T) {
dir := t.TempDir()
modelDir := filepath.Join(dir, "model")
Expand Down
69 changes: 50 additions & 19 deletions cmd/mxcli/docker/runlocal.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,21 +182,6 @@ func (o *LocalRunOptions) applyDefaults() {
if o.PollInterval == 0 {
o.PollInterval = time.Second
}
if o.DB.Type == "" {
o.DB.Type = "PostgreSQL"
}
if o.DB.Host == "" {
o.DB.Host = "127.0.0.1:5432"
}
if o.DB.User == "" {
o.DB.User = "mendix"
}
if o.DB.Password == "" {
o.DB.Password = "mendix"
}
if o.DB.Name == "" {
o.DB.Name = deriveDBName(o.ProjectPath)
}
if o.ScreenshotPath == "" {
o.ScreenshotPath = filepath.Join(filepath.Dir(o.ProjectPath), ".mxcli", "run-local.png")
}
Expand All @@ -214,6 +199,47 @@ func (o *LocalRunOptions) applyDefaults() {
}
}

// applyDatabaseDefaults validates --db-type and fills the connection settings the
// chosen database needs. It is separate from applyDefaults because it can fail:
// a flag combination that cannot work (--db-type hsqldb with --db-host) is a user
// error, not something to silently normalise away.
func (o *LocalRunOptions) applyDatabaseDefaults() error {
kind, err := NormalizeDBType(o.DB.Type)
if err != nil {
return err
}
if o.DB.Name == "" {
o.DB.Name = deriveDBName(o.ProjectPath)
}
if IsFileBasedDBType(kind) {
// The built-in database is a file: it has no host or credentials, and
// accepting them would imply a connection that never happens.
if o.DB.Host != "" || o.DB.User != "" || o.DB.Password != "" {
return fmt.Errorf("--db-type hsqldb uses the built-in file database and takes no " +
"--db-host, --db-user or --db-password")
}
if o.EnsureDB {
return fmt.Errorf("--ensure-db provisions PostgreSQL; the built-in HSQLDB database " +
"needs no provisioning — drop --ensure-db")
}
o.DB.Type = RuntimeDatabaseType(kind)
o.DB.Host, o.DB.User, o.DB.Password = "", "", ""
return nil
}
// PostgreSQL (unchanged behaviour).
if o.DB.Host == "" {
o.DB.Host = "127.0.0.1:5432"
}
if o.DB.User == "" {
o.DB.User = "mendix"
}
if o.DB.Password == "" {
o.DB.Password = "mendix"
}
o.DB.Type = RuntimeDatabaseType(kind)
return nil
}

// defaultOtelSpanFilters are the internal runtime spans suppressed under --trace.
// Unfiltered per-activity tracing is ~10x slower; these bring it near baseline
// while keeping the microflow-level spans (findings — OpenTelemetry).
Expand Down Expand Up @@ -541,6 +567,9 @@ func sourceMTime(projectPath string) time.Time {
// and hot-apply on every project change until interrupted.
func RunLocal(opts LocalRunOptions) error {
opts.applyDefaults()
if err := opts.applyDatabaseDefaults(); err != nil {
return err
}
w, stderr := opts.Stdout, opts.Stderr

// 0. Refuse fast if the loop's ports are already taken (a stale run/serve/
Expand Down Expand Up @@ -610,10 +639,12 @@ func RunLocal(opts LocalRunOptions) error {
if err := EnsureDatabase(&opts.DB, w); err != nil {
return fmt.Errorf("ensuring database: %w", err)
}
} else if err := pingTCP(opts.DB.Host, 3*time.Second); err != nil {
return fmt.Errorf("database not reachable at %s: %w\n"+
" Pass --ensure-db to provision it, or start Postgres and create the '%s' database (user %q).",
opts.DB.Host, err, opts.DB.Name, opts.DB.User)
} else if !opts.DB.IsFileBased() {
if err := pingTCP(opts.DB.Host, 3*time.Second); err != nil {
return fmt.Errorf("database not reachable at %s: %w\n"+
" Pass --ensure-db to provision it, or start Postgres and create the '%s' database (user %q).",
opts.DB.Host, err, opts.DB.Name, opts.DB.User)
}
}

// Setup-only: prerequisites are ready (mxbuild+runtime cached, database up).
Expand Down
112 changes: 112 additions & 0 deletions cmd/mxcli/docker/runlocal_hsqldb_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: Apache-2.0

//go:build integration

package docker

import (
"bytes"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

// The point of the built-in database: an app boots with NO database server.
// Modeled on localapp_integration_test.go — scaffold a project when no fixture
// is given, build it with a real mxbuild serve, then boot the runtime with
// HSQLDB and require it to answer. Self-contained: HSQLDB is a file, so there is
// nothing else to stand up.
func TestRunLocal_HSQLDBNeedsNoDatabaseServer(t *testing.T) {
mprPath := os.Getenv("MXCLI_IT_PROJECT")
if mprPath == "" {
mxPath, err := ResolveMx("")
if err != nil {
t.Skipf("mx not resolvable and MXCLI_IT_PROJECT unset: %v", err)
}
dir, err := os.MkdirTemp("", "mxhsql")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
scaffold := exec.Command(mxPath, "create-project")
scaffold.Dir = dir
if out, err := scaffold.CombinedOutput(); err != nil {
t.Skipf("mx create-project failed: %v\n%s", err, out)
}
mprPath = filepath.Join(dir, "App.mpr")
}
if _, err := os.Stat(mprPath); err != nil {
t.Skipf("no project fixture at %s: %v", mprPath, err)
}

reader, err := openReadOnly(mprPath)
if err != nil {
t.Skipf("cannot open project: %v", err)
}
version := reader.ProjectVersion().ProductVersion
reader.Disconnect()

installPath, err := resolveRuntimeInstall(version, io.Discard)
if err != nil {
t.Skipf("no runtime for %s: %v", version, err)
}
javaMajor, _ := ProjectJavaMajor(mprPath)

serve, err := StartServe(ServeOptions{Version: version, JavaMajor: javaMajor, Host: "127.0.0.1", Port: 6549})
if err != nil {
t.Skipf("cannot start mxbuild serve: %v", err)
}
defer serve.Stop()

build, err := serve.Build(BuildRequest{Target: TargetDeploy, ProjectFilePath: mprPath})
if err != nil {
t.Fatalf("build: %v", err)
}
if !build.OK() {
t.Fatalf("build failed: %s", build.Message)
}

deployDir := filepath.Join(filepath.Dir(mprPath), "deployment")
var rtOut bytes.Buffer
rt, err := StartLocalRuntime(LocalRuntimeOptions{
DeployDir: deployDir,
InstallPath: installPath,
JavaMajor: javaMajor,
AdminPass: defaultLocalAdminPass,
AppPort: 8087,
AdminPort: 8097,
DB: DBConfig{Type: "HSQLDB", Name: deriveDBName(mprPath)},
Stdout: &rtOut,
Stderr: &rtOut,
})
if err != nil {
t.Fatalf("boot with the built-in database: %v", err)
}
defer rt.Stop()

client := &http.Client{Timeout: 5 * time.Second}
deadline := time.Now().Add(90 * time.Second)
for {
resp, err := client.Get(rt.AppURL())
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
break
}
}
if time.Now().After(deadline) {
t.Fatalf("app did not answer 200 at %s\n--- runtime output ---\n%s", rt.AppURL(), rt.Log())
}
time.Sleep(time.Second)
}

hsqlDir := filepath.Join(deployDir, "data", "database", "hsqldb")
entries, err := os.ReadDir(hsqlDir)
if err != nil || len(entries) == 0 {
t.Fatalf("expected the built-in database files under %s (err=%v, n=%d)", hsqlDir, err, len(entries))
}
}
Loading
Loading