From 8303b19bd52252d8a17378d0b8ea2b9a4aecbc86 Mon Sep 17 00:00:00 2001 From: engalar Date: Fri, 10 Apr 2026 00:17:44 +0800 Subject: [PATCH] fix: docker cache detection on Windows and reload schema warning Fix #126: CachedMxBuildPath/AnyCachedMxBuildPath/findMxBuildInDir now check both "mxbuild.exe" and "mxbuild" on Windows, so the Linux binary cached for Docker is found without re-downloading the 394MB tarball. Fix #115: After reload_model succeeds, call get_ddl_commands to detect pending schema changes. If DDL is pending, print a warning with the actual DDL text and suggest using 'docker up --fresh'. --- cmd/mxcli/docker/detect.go | 27 +++++++++---- cmd/mxcli/docker/download.go | 36 +++++++++++------ cmd/mxcli/docker/reload.go | 51 +++++++++++++++++++++++++ cmd/mxcli/docker/reload_test.go | 68 ++++++++++++++++++++++++++++++--- 4 files changed, 156 insertions(+), 26 deletions(-) diff --git a/cmd/mxcli/docker/detect.go b/cmd/mxcli/docker/detect.go index ec56c19b..b2759ae1 100644 --- a/cmd/mxcli/docker/detect.go +++ b/cmd/mxcli/docker/detect.go @@ -21,17 +21,28 @@ func mxbuildBinaryName() string { return "mxbuild" } +// mxbuildBinaryNames returns all candidate binary names for mxbuild. +// On Windows, the Linux binary ("mxbuild") may also be cached when downloaded +// for Docker, so both names must be checked. +func mxbuildBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"mxbuild.exe", "mxbuild"} + } + return []string{"mxbuild"} +} + // findMxBuildInDir looks for the mxbuild binary inside a directory. // Checks: dir/mxbuild, dir/modeler/mxbuild (Mendix installation layout). func findMxBuildInDir(dir string) string { - bin := mxbuildBinaryName() - candidates := []string{ - filepath.Join(dir, bin), - filepath.Join(dir, "modeler", bin), - } - for _, c := range candidates { - if info, err := os.Stat(c); err == nil && !info.IsDir() { - return c + for _, bin := range mxbuildBinaryNames() { + candidates := []string{ + filepath.Join(dir, bin), + filepath.Join(dir, "modeler", bin), + } + for _, c := range candidates { + if info, err := os.Stat(c); err == nil && !info.IsDir() { + return c + } } } return "" diff --git a/cmd/mxcli/docker/download.go b/cmd/mxcli/docker/download.go index 470cebd7..260f7df4 100644 --- a/cmd/mxcli/docker/download.go +++ b/cmd/mxcli/docker/download.go @@ -38,30 +38,35 @@ func MxBuildCDNURL(version, goarch string) string { // CachedMxBuildPath returns the path to a cached mxbuild binary for the given version, // or empty string if not cached. +// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker). func CachedMxBuildPath(version string) string { cacheDir, err := MxBuildCacheDir(version) if err != nil { return "" } - bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName()) - if info, err := os.Stat(bin); err == nil && !info.IsDir() { - return bin + for _, name := range mxbuildBinaryNames() { + bin := filepath.Join(cacheDir, "modeler", name) + if info, err := os.Stat(bin); err == nil && !info.IsDir() { + return bin + } } return "" } // AnyCachedMxBuildPath searches for any cached mxbuild version. // Returns the path to the first mxbuild binary found, or empty string. +// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker). func AnyCachedMxBuildPath() string { home, err := os.UserHomeDir() if err != nil { return "" } - pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxbuildBinaryName()) - matches, _ := filepath.Glob(pattern) - if len(matches) > 0 { - // Return the last match (likely newest version by lexicographic sort) - return matches[len(matches)-1] + for _, name := range mxbuildBinaryNames() { + pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", name) + matches, _ := filepath.Glob(pattern) + if len(matches) > 0 { + return matches[len(matches)-1] + } } return "" } @@ -113,11 +118,18 @@ func DownloadMxBuild(version string, w io.Writer) (string, error) { return "", fmt.Errorf("extracting mxbuild: %w", err) } - // Verify the binary exists - bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName()) - if _, err := os.Stat(bin); err != nil { + // Verify the binary exists (check all candidate names) + var bin string + for _, name := range mxbuildBinaryNames() { + candidate := filepath.Join(cacheDir, "modeler", name) + if _, err := os.Stat(candidate); err == nil { + bin = candidate + break + } + } + if bin == "" { os.RemoveAll(cacheDir) - return "", fmt.Errorf("mxbuild binary not found after extraction (expected %s)", bin) + return "", fmt.Errorf("mxbuild binary not found after extraction (looked in %s/modeler/)", cacheDir) } fmt.Fprintf(w, " MxBuild cached at %s\n", bin) diff --git a/cmd/mxcli/docker/reload.go b/cmd/mxcli/docker/reload.go index ed516175..aa34c868 100644 --- a/cmd/mxcli/docker/reload.go +++ b/cmd/mxcli/docker/reload.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" "time" ) @@ -110,9 +111,59 @@ func Reload(opts ReloadOptions) error { fmt.Fprintln(w, "Model reloaded.") } + // Check for pending schema changes (new/dropped entities or attributes). + // reload_model only reloads the in-memory model definition; it does not + // sync the database schema. If DDL changes are pending, the app will crash + // at runtime with "Entity does not exist" or similar errors. + if pending := checkPendingDDL(m2eeOpts); pending != "" { + fmt.Fprintln(w, "") + fmt.Fprintln(w, "WARNING: Database schema changes detected after reload.") + fmt.Fprintln(w, " The model was reloaded, but new entities or attributes require") + fmt.Fprintln(w, " a database schema update that hot-reload cannot perform.") + fmt.Fprintln(w, "") + fmt.Fprintln(w, " Pending DDL:") + for _, line := range strings.Split(pending, "\n") { + if strings.TrimSpace(line) != "" { + fmt.Fprintf(w, " %s\n", line) + } + } + fmt.Fprintln(w, "") + fmt.Fprintln(w, " Fix: run 'mxcli docker up --fresh' to restart with schema sync.") + } + return nil } +// checkPendingDDL queries the runtime for pending DDL commands. +// Returns the DDL text if changes are pending, or empty string if none or on error. +func checkPendingDDL(opts M2EEOptions) string { + resp, err := CallM2EE(opts, "get_ddl_commands", nil) + if err != nil { + return "" + } + if resp.Result != 0 { + return "" + } + + feedback := resp.Feedback() + if feedback == nil { + return "" + } + + // The M2EE get_ddl_commands action returns DDL in feedback.ddl_commands + ddl, ok := feedback["ddl_commands"] + if !ok { + return "" + } + + ddlStr, ok := ddl.(string) + if !ok || strings.TrimSpace(ddlStr) == "" { + return "" + } + + return ddlStr +} + // extractReloadDuration extracts the duration from feedback.startup_metrics.duration. func extractReloadDuration(feedback map[string]any) string { if feedback == nil { diff --git a/cmd/mxcli/docker/reload_test.go b/cmd/mxcli/docker/reload_test.go index 37648746..5c0b9f14 100644 --- a/cmd/mxcli/docker/reload_test.go +++ b/cmd/mxcli/docker/reload_test.go @@ -49,14 +49,20 @@ func TestReload_CSSOnly(t *testing.T) { } func TestReload_ModelOnly(t *testing.T) { - var receivedAction string + var actions []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any json.NewDecoder(r.Body).Decode(&body) - receivedAction = body["action"].(string) + action := body["action"].(string) + actions = append(actions, action) w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`)) + switch action { + case "reload_model": + w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`)) + case "get_ddl_commands": + w.Write([]byte(`{"result":0,"feedback":{}}`)) + } })) defer server.Close() @@ -77,8 +83,8 @@ func TestReload_ModelOnly(t *testing.T) { t.Fatalf("Reload: %v", err) } - if receivedAction != "reload_model" { - t.Errorf("expected reload_model action, got %q", receivedAction) + if len(actions) < 2 || actions[0] != "reload_model" || actions[1] != "get_ddl_commands" { + t.Errorf("expected actions [reload_model, get_ddl_commands], got %v", actions) } if !strings.Contains(stdout.String(), "Model reloaded") { t.Errorf("expected 'Model reloaded' in output, got: %s", stdout.String()) @@ -142,8 +148,15 @@ func TestReload_ParseDuration(t *testing.T) { func TestReload_ModelOnly_WithDuration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`)) + switch body["action"].(string) { + case "reload_model": + w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`)) + case "get_ddl_commands": + w.Write([]byte(`{"result":0,"feedback":{}}`)) + } })) defer server.Close() @@ -197,6 +210,49 @@ func TestReload_CSSOnly_Error(t *testing.T) { } } +func TestReload_ModelOnly_PendingDDL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + switch body["action"].(string) { + case "reload_model": + w.Write([]byte(`{"result":0,"feedback":{}}`)) + case "get_ddl_commands": + w.Write([]byte(`{"result":0,"feedback":{"ddl_commands":"CREATE TABLE mymodule$customer (id BIGINT NOT NULL);\nALTER TABLE mymodule$order ADD COLUMN status VARCHAR(200);"}}`)) + } + })) + defer server.Close() + + host, port := parseTestServerAddr(t, server.URL) + + var stdout bytes.Buffer + opts := ReloadOptions{ + SkipBuild: true, + Host: host, + Port: port, + Token: "testpass", + Direct: true, + Stdout: &stdout, + } + + err := Reload(opts) + if err != nil { + t.Fatalf("Reload: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "WARNING: Database schema changes detected") { + t.Errorf("expected DDL warning in output, got: %s", output) + } + if !strings.Contains(output, "CREATE TABLE") { + t.Errorf("expected DDL commands in output, got: %s", output) + } + if !strings.Contains(output, "docker up --fresh") { + t.Errorf("expected fix suggestion in output, got: %s", output) + } +} + func TestReload_ModelOnly_ReloadError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")