Skip to content

Commit 8303b19

Browse files
committed
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'.
1 parent 061531a commit 8303b19

4 files changed

Lines changed: 156 additions & 26 deletions

File tree

cmd/mxcli/docker/detect.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,28 @@ func mxbuildBinaryName() string {
2121
return "mxbuild"
2222
}
2323

24+
// mxbuildBinaryNames returns all candidate binary names for mxbuild.
25+
// On Windows, the Linux binary ("mxbuild") may also be cached when downloaded
26+
// for Docker, so both names must be checked.
27+
func mxbuildBinaryNames() []string {
28+
if runtime.GOOS == "windows" {
29+
return []string{"mxbuild.exe", "mxbuild"}
30+
}
31+
return []string{"mxbuild"}
32+
}
33+
2434
// findMxBuildInDir looks for the mxbuild binary inside a directory.
2535
// Checks: dir/mxbuild, dir/modeler/mxbuild (Mendix installation layout).
2636
func findMxBuildInDir(dir string) string {
27-
bin := mxbuildBinaryName()
28-
candidates := []string{
29-
filepath.Join(dir, bin),
30-
filepath.Join(dir, "modeler", bin),
31-
}
32-
for _, c := range candidates {
33-
if info, err := os.Stat(c); err == nil && !info.IsDir() {
34-
return c
37+
for _, bin := range mxbuildBinaryNames() {
38+
candidates := []string{
39+
filepath.Join(dir, bin),
40+
filepath.Join(dir, "modeler", bin),
41+
}
42+
for _, c := range candidates {
43+
if info, err := os.Stat(c); err == nil && !info.IsDir() {
44+
return c
45+
}
3546
}
3647
}
3748
return ""

cmd/mxcli/docker/download.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,30 +38,35 @@ func MxBuildCDNURL(version, goarch string) string {
3838

3939
// CachedMxBuildPath returns the path to a cached mxbuild binary for the given version,
4040
// or empty string if not cached.
41+
// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker).
4142
func CachedMxBuildPath(version string) string {
4243
cacheDir, err := MxBuildCacheDir(version)
4344
if err != nil {
4445
return ""
4546
}
46-
bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName())
47-
if info, err := os.Stat(bin); err == nil && !info.IsDir() {
48-
return bin
47+
for _, name := range mxbuildBinaryNames() {
48+
bin := filepath.Join(cacheDir, "modeler", name)
49+
if info, err := os.Stat(bin); err == nil && !info.IsDir() {
50+
return bin
51+
}
4952
}
5053
return ""
5154
}
5255

5356
// AnyCachedMxBuildPath searches for any cached mxbuild version.
5457
// Returns the path to the first mxbuild binary found, or empty string.
58+
// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker).
5559
func AnyCachedMxBuildPath() string {
5660
home, err := os.UserHomeDir()
5761
if err != nil {
5862
return ""
5963
}
60-
pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxbuildBinaryName())
61-
matches, _ := filepath.Glob(pattern)
62-
if len(matches) > 0 {
63-
// Return the last match (likely newest version by lexicographic sort)
64-
return matches[len(matches)-1]
64+
for _, name := range mxbuildBinaryNames() {
65+
pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", name)
66+
matches, _ := filepath.Glob(pattern)
67+
if len(matches) > 0 {
68+
return matches[len(matches)-1]
69+
}
6570
}
6671
return ""
6772
}
@@ -113,11 +118,18 @@ func DownloadMxBuild(version string, w io.Writer) (string, error) {
113118
return "", fmt.Errorf("extracting mxbuild: %w", err)
114119
}
115120

116-
// Verify the binary exists
117-
bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName())
118-
if _, err := os.Stat(bin); err != nil {
121+
// Verify the binary exists (check all candidate names)
122+
var bin string
123+
for _, name := range mxbuildBinaryNames() {
124+
candidate := filepath.Join(cacheDir, "modeler", name)
125+
if _, err := os.Stat(candidate); err == nil {
126+
bin = candidate
127+
break
128+
}
129+
}
130+
if bin == "" {
119131
os.RemoveAll(cacheDir)
120-
return "", fmt.Errorf("mxbuild binary not found after extraction (expected %s)", bin)
132+
return "", fmt.Errorf("mxbuild binary not found after extraction (looked in %s/modeler/)", cacheDir)
121133
}
122134

123135
fmt.Fprintf(w, " MxBuild cached at %s\n", bin)

cmd/mxcli/docker/reload.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"strings"
910
"time"
1011
)
1112

@@ -110,9 +111,59 @@ func Reload(opts ReloadOptions) error {
110111
fmt.Fprintln(w, "Model reloaded.")
111112
}
112113

114+
// Check for pending schema changes (new/dropped entities or attributes).
115+
// reload_model only reloads the in-memory model definition; it does not
116+
// sync the database schema. If DDL changes are pending, the app will crash
117+
// at runtime with "Entity does not exist" or similar errors.
118+
if pending := checkPendingDDL(m2eeOpts); pending != "" {
119+
fmt.Fprintln(w, "")
120+
fmt.Fprintln(w, "WARNING: Database schema changes detected after reload.")
121+
fmt.Fprintln(w, " The model was reloaded, but new entities or attributes require")
122+
fmt.Fprintln(w, " a database schema update that hot-reload cannot perform.")
123+
fmt.Fprintln(w, "")
124+
fmt.Fprintln(w, " Pending DDL:")
125+
for _, line := range strings.Split(pending, "\n") {
126+
if strings.TrimSpace(line) != "" {
127+
fmt.Fprintf(w, " %s\n", line)
128+
}
129+
}
130+
fmt.Fprintln(w, "")
131+
fmt.Fprintln(w, " Fix: run 'mxcli docker up --fresh' to restart with schema sync.")
132+
}
133+
113134
return nil
114135
}
115136

137+
// checkPendingDDL queries the runtime for pending DDL commands.
138+
// Returns the DDL text if changes are pending, or empty string if none or on error.
139+
func checkPendingDDL(opts M2EEOptions) string {
140+
resp, err := CallM2EE(opts, "get_ddl_commands", nil)
141+
if err != nil {
142+
return ""
143+
}
144+
if resp.Result != 0 {
145+
return ""
146+
}
147+
148+
feedback := resp.Feedback()
149+
if feedback == nil {
150+
return ""
151+
}
152+
153+
// The M2EE get_ddl_commands action returns DDL in feedback.ddl_commands
154+
ddl, ok := feedback["ddl_commands"]
155+
if !ok {
156+
return ""
157+
}
158+
159+
ddlStr, ok := ddl.(string)
160+
if !ok || strings.TrimSpace(ddlStr) == "" {
161+
return ""
162+
}
163+
164+
return ddlStr
165+
}
166+
116167
// extractReloadDuration extracts the duration from feedback.startup_metrics.duration.
117168
func extractReloadDuration(feedback map[string]any) string {
118169
if feedback == nil {

cmd/mxcli/docker/reload_test.go

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,20 @@ func TestReload_CSSOnly(t *testing.T) {
4949
}
5050

5151
func TestReload_ModelOnly(t *testing.T) {
52-
var receivedAction string
52+
var actions []string
5353
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5454
var body map[string]any
5555
json.NewDecoder(r.Body).Decode(&body)
56-
receivedAction = body["action"].(string)
56+
action := body["action"].(string)
57+
actions = append(actions, action)
5758

5859
w.Header().Set("Content-Type", "application/json")
59-
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
60+
switch action {
61+
case "reload_model":
62+
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
63+
case "get_ddl_commands":
64+
w.Write([]byte(`{"result":0,"feedback":{}}`))
65+
}
6066
}))
6167
defer server.Close()
6268

@@ -77,8 +83,8 @@ func TestReload_ModelOnly(t *testing.T) {
7783
t.Fatalf("Reload: %v", err)
7884
}
7985

80-
if receivedAction != "reload_model" {
81-
t.Errorf("expected reload_model action, got %q", receivedAction)
86+
if len(actions) < 2 || actions[0] != "reload_model" || actions[1] != "get_ddl_commands" {
87+
t.Errorf("expected actions [reload_model, get_ddl_commands], got %v", actions)
8288
}
8389
if !strings.Contains(stdout.String(), "Model reloaded") {
8490
t.Errorf("expected 'Model reloaded' in output, got: %s", stdout.String())
@@ -142,8 +148,15 @@ func TestReload_ParseDuration(t *testing.T) {
142148

143149
func TestReload_ModelOnly_WithDuration(t *testing.T) {
144150
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151+
var body map[string]any
152+
json.NewDecoder(r.Body).Decode(&body)
145153
w.Header().Set("Content-Type", "application/json")
146-
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
154+
switch body["action"].(string) {
155+
case "reload_model":
156+
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
157+
case "get_ddl_commands":
158+
w.Write([]byte(`{"result":0,"feedback":{}}`))
159+
}
147160
}))
148161
defer server.Close()
149162

@@ -197,6 +210,49 @@ func TestReload_CSSOnly_Error(t *testing.T) {
197210
}
198211
}
199212

213+
func TestReload_ModelOnly_PendingDDL(t *testing.T) {
214+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215+
var body map[string]any
216+
json.NewDecoder(r.Body).Decode(&body)
217+
w.Header().Set("Content-Type", "application/json")
218+
switch body["action"].(string) {
219+
case "reload_model":
220+
w.Write([]byte(`{"result":0,"feedback":{}}`))
221+
case "get_ddl_commands":
222+
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);"}}`))
223+
}
224+
}))
225+
defer server.Close()
226+
227+
host, port := parseTestServerAddr(t, server.URL)
228+
229+
var stdout bytes.Buffer
230+
opts := ReloadOptions{
231+
SkipBuild: true,
232+
Host: host,
233+
Port: port,
234+
Token: "testpass",
235+
Direct: true,
236+
Stdout: &stdout,
237+
}
238+
239+
err := Reload(opts)
240+
if err != nil {
241+
t.Fatalf("Reload: %v", err)
242+
}
243+
244+
output := stdout.String()
245+
if !strings.Contains(output, "WARNING: Database schema changes detected") {
246+
t.Errorf("expected DDL warning in output, got: %s", output)
247+
}
248+
if !strings.Contains(output, "CREATE TABLE") {
249+
t.Errorf("expected DDL commands in output, got: %s", output)
250+
}
251+
if !strings.Contains(output, "docker up --fresh") {
252+
t.Errorf("expected fix suggestion in output, got: %s", output)
253+
}
254+
}
255+
200256
func TestReload_ModelOnly_ReloadError(t *testing.T) {
201257
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202258
w.Header().Set("Content-Type", "application/json")

0 commit comments

Comments
 (0)