Repository navigation
Wheels 4.1.1
Added
WHEELS_SERVER_FALLBACK=false, in the environment or the project's.env, stops read-side CLI commands (wheels db status,db version,migrate info, …) from falling back to whatever server answers on a common port (8080, 60000, 3000, 8500) when this project's own port isn't serving. Without it, such a command can silently read a different app's server (#3693)
Changed
- CLI boolean flags accept only
trueorfalse:--force=yes,--force=1, or any other value now fails withWheels.InvalidArgumentsinstead of being read loosely (a bare--forcestill means true, and--no-forcefalse). Numeric options reject non-numbers, and a string option given as a bare flag (e.g.--towith no value) is an error (#2963) - CLI commands and MCP tools reject unknown named arguments and named arguments with no value (an empty MCP string arrives as null) with
Wheels.InvalidArgumentsnaming the argument, instead of ignoring them. The global--offlineflag is still accepted by every command (#2963) deployis no longer exposed as an MCP tool: it runs SSH, image pushes, and container restarts on remote hosts, so it stays CLI-only likenew,start, andsetup(#2963)- The MCP
testtool now prints a warning when itstimeoutis not a positive number, e.g.Warning: ignoring invalid timeout "x"; using 900s. The run still goes ahead with the default, as before; it just no longer falls back silently (#2963) wheels generate app <name> --offlinenow passes--offlinethrough towheels new, which skips its update check; before, generate consumed the flag andnewreset it (#2963)- Upgrade note (4.0.x → 4.1.x) — validation
condition/unlessexpressions now fail closed. Acondition/unlessstring that the evaluator cannot parse (a compound&&/||expression, or a function call with arguments outside the read-only whitelist:StructKeyExists,IsNull,IsNumeric,IsSimpleValue,IsStruct,IsArray,IsBoolean,IsDate,Len,ListFind,ListFindNoCase,ListContains,ListContainsNoCase) now throwsWheels.InvalidValidationCondition— an HTTP 500 on the save that triggers it. 4.0.x caught the same parse failure and returnedfalse, which skipped the validation entirely: app authors should treat a 4.1 upgrade as the moment previously-inert validations start being enforced, and audit anycondition/unlessthat names a property inside a function call. Two real cases surfaced on one production app during the 4.1 upgrade:condition="StructKeyExists(this, 'requestFor') && this.requestFor == 'Engine Part'"(500 on every save) andcondition="ListFind('24,32,63,67,117,167,191', this.countryId)"(silently inert — the validation never ran). Fail-closed is deliberate: silently skipping a validation hides data-integrity holes, which is exactly how both of those survived for years.wheels-dev/wheels#3634(#3634) - CI and Linux packages take the LuCLI runtime from one pin,
tools/lucli.json, and verify each download against a pinned SHA-256 and the version it reports; the Homebrew and Scoop updaters read the same pin. Once the channels ship the fork runtime (with the module-timeout fix, LuCLI #130), a terminalwheels test --timeout=<seconds>reaches the test command;--test-timeoutkeeps working on every runtime. - Update the experimental RustCFML engine pin to v0.691.0 with a Linux-verified baseline; keep JVM-only system-property expectations capability-aware
Performance
- Flash locking is now scoped to where the data lives: session-stored flash takes a per-session lock instead of one application-wide lock, so users no longer queue behind each other on every request, and cookie-stored flash (request-local) is no longer locked at all. The end-of-request
$flashClear()also skips its exclusive write when the session flash is already empty. Under a 128-user load test this lifted throughput 10–14%.
Fixed
wheels generateaccepts an explicitdry-run=false(sent by MCP clients as--no-dry-run) instead of failing withUnknown flag, and a failed dry run no longer leaves latergeneratecalls in the same MCP session writing nothing.wheels analyzerejects an unknown target instead of silently analyzing everything (#2963)- Fixed-choice CLI and MCP parameters now reject values outside their set instead of silently falling back:
routesformat,seedmode(an unknown mode used to generate rows),testreporter,analyzetarget,destroytype(which also exits non-zero now), thedb/packages/upgradeverbs,upgradeformat,migrateaction, and thegenerate/createtypes. MCP tool schemas advertise the valid values as a JSON Schemaenum, from the sameArgSpecdeclaration the parser enforces (#2963) wheels migrate diffon a missing model and a refusedwheels migrate rename-system-tablesnow exit non-zero, andwheels testexits non-zero on any runner response without test counts (such as a missing-datasource error) (#2963)migrateandcreateMCP tool calls now honour every advertised parameter. Both rebuilt argv from named keys in struct order, so{action: "info", "dry-run": false}failed asUnknown migration action: --no-dry-run,forget/pretendlost theirversion,difflost itsmodel, and{name, type}reachedcreateasUnknown create type: --name=x. A later MCP call no longer stays offline because an earlier one passedoffline, and a bare--name-style flag no longer binds the literaltrueas a positional value (#2963)- MCP tool calls now honour positional parameters. LuCLI's MCP server passes every
tools/callargument by name, andArgSpec.parse()bound positionals only from typed tokens, so advertised properties such asdestroy'stype/nameandanalyze'stargetwere silently ignored.db,packages, andreloadalso advertise real input schemas now, built from the sameArgSpectheir parsers use (#2963) wheels test(and the MCPtesttool) now exits non-zero when the test runner refuses before running anything, e.g. a failed test-database migration. The runner answers with a{success: false, error, message}document that has no test counts, which the CLI used to read as a clean run; it now prints the runner's explanation and fails (#2963)- Model validations:
condition/unlessexpressions that combine clauses with&&/||no longer throwUnsupported operator in condition.$evaluateConditionString()now splits a compound boolean at the top level (quote- and paren-aware, with&&binding tighter than||) and evaluates each side with the existing single-clause machinery, and it recognises a small whitelist of read-only function calls with arguments, including the idiomaticStructKeyExists(this, 'property')and the list predicatesListFind/ListFindNoCase/ListContains/ListContainsNoCase(so legacy conditions of the shapeListFind('a,b,c', this.id)keep working instead of newly throwing). Previously any such expression fell through to the three-token$evaluateLogicalExpression()parser, which read the wrong token as the operator; because 4.1.0 made that path fail closed, the symptom changed from a silently skipped validation to a thrownWheels.InvalidValidationCondition(HTTP 500 on save). The widened grammar also accepts a leading!on any supported clause (!StructKeyExists(this, 'x'), including inside a compound expression), parenthesised groups ((a || b) && c), and symbolic operators (==,!=) within a compound clause. A whitelisted function argument that names a property absent from the instance now resolves to an empty value instead of throwing, soStructKeyExists(this, 'a') || StructKeyExists(this, 'b')short-circuits as written rather than 500ing on the first clause. A whitelisted function called with the wrong number of arguments reports an arity error rather than being described as unsupported. Note: a function-call condition outside the whitelist still throws here whereas 4.1.0 silently returned false — see the PR description for that remaining upgrade hazard (#3634) paginationLinks()no longer adopts an ambiguous ambient route name, so a paginatedindexaction reached through the catch-allwildcard()route (the default scaffolded routes) no longer throwsWheels.RouteNotFound— which rendered as a whole-page 404 in production, with nothing logged, because the route error path aborts. The ambientrequest.wheels.params.routename from the #942 fix is now adopted only when it resolves to a single named route; thewildcardname registers one candidate per method x pattern, andpaginationLinks()supplies neither a method nor the route's path variables, so$findRoute()could never disambiguate it. Falling through to the controller/action URL produces the same link the wildcard route serves (#3638)paginationLinks(route="")is now a real opt-out: an explicitly emptyrouteis treated as "no route" instead of being passed to$findRoute(), which threwWheels.RouteNotFoundfor the empty name and gave applications no way to suppress the ambient-route adoption in the meantime (#3638)PackageLoaderresolves a package's entry-point CFC deterministically instead of taking whichever*.cfcthe directory listing returns first. A package with more than one root CFC now declares it with"main": "Sentry"inpackage.json, or is matched by the package name (wheels-seo-suite→SeoSuite.cfc); anything still ambiguous fails loudly and names the candidates instead of loading the wrong component (#3639)PackageLoadernow verifies that each package mapping alias it registers is actually resolvable by the engine (ExpandPath()probe) and reports the ones the engine ignored throughgetUnresolvedMappings(), plus one boot log line. Lucee ignoresapplication.mappingswritten after application start, so a package referencing a sibling alias used to fail at first use with a bare "could not find component" while the loader reported the alias as registered (#3639)- A
.envvalue of numeric 1 is no longer coerced to the booleantrue. The generator template, the bundled examples and theconfig dump/get settings/reloadCLI commands all tested booleans with==, which Lucee compares NUMERICALLY —"1.0" == "true"is true — so a numeric setting such as a sample rate of1.0arrived as a boolean and silently took its default instead (#3641) - Adobe ColdFusion 2025 no longer breaks the framework's own debug surfaces: the debug bar's complexity panel, the development error page's code frame, the docs viewer's extended examples, and legacy RocketUnit failure messages all called
HtmlEditFormat(), a BIF Adobe CF 2025 removed (Variable HTMLEDITFORMAT is undefined). Each call now goes through$encodeForDisplayText(), which keeps using the BIF where the engine provides it (it preserves/, so file paths stay readable) and falls back to an equivalent local escaper otherwise. On the compat matrix the complexity panel alone produced twelveVariable HTMLEDITFORMAT is undefinederrors on every adobe2025 leg, and a new structural guard (HtmlEditFormatGuardSpec) fails the suite if a direct BIF call reappears anywhere undervendor/wheels(#3645) create()with an explicit primary key now works on SQL Server.model("Post").create(id = 41, ...)used to fail withCannot insert explicit value for identity column ... when IDENTITY_INSERT is set to OFF, while every other supported database accepted it. When an INSERT's column list carries the primary key, the SQL Server adapter now sends it in one batch withSET IDENTITY_INSERT <table> ONbefore andOFFafter, both guarded by asys.identity_columnslookup so a natural or UUID key stays a plain INSERT. The OFF runs even when the INSERT hits a constraint violation, so a failed insert cannot leave the setting on for the pooled connection (#3647)- Timestamp values read back from the database are normalized before the framework measures elapsed time against them, fixing Oracle on Adobe CF: the Oracle JDBC driver returns TIMESTAMP columns as
oracle.sql.TIMESTAMP, which is not ajava.util.Date, soIsDate()rejected it —RateLimiter's database-backed token bucket read every bucket as permanently empty or full, andJobWorker.getMonitorData()returned a non-dateoldestPending. The new$normalizeDbTimestamp()helper accepts a CFML date, plain or boxed epoch milliseconds (Adobe + sqlite-jdbc), ajava.util.Date, Oracle'soracle.sql.TIMESTAMP(bridged throughtimestampValue()) and fractional-second datetime strings, and replaces the SQLite-only inline epoch conversion inJobWorker(#3649) insertAll()no longer fails on Oracle under Lucee withORA-03048: SQL reserved word 'ROWID' is not syntactically valid following '... FROM dual RETURNING'. A cfqueryresultattribute makes Lucee request generated keys from the driver, and the Oracle driver implements that by appendingRETURNING ROWID INTO ?to every INSERT, which Oracle rejects after theINSERT ... SELECT ... FROM dual UNION ALLbulk shape. Nothing on the bulk paths reads the result or a key, soinsertAll()andupsertAll()now run their statements without one ($performQuery(..., $captureResult = false)); the Lucee + Oracle core suite goes from fourbulkOperationsSpecerrors to green (#3653)- Table names containing
$(e.g.admin$users) work again: 4.1.0's$dbinfo()identifier check rejected them and stopped the app from initializing.$is now accepted after the first character there and in the AutoMigrator, ORDER BY / GROUP BY dot-notation, and QueryBuilder identifier checks, so a paginatedfindAll()on a$table works too; those identifier checks still reject quotes,#, and a leading$(#3669) - When Wheels fails to initialize, the minimal "Application Error" page no longer points at a log entry that does not exist:
onError()'s fallback now writes the failure towheels.log(typeerror) before rendering, walkingRootCause/Causeso the real type, message, detail and first tag-context frame are logged even when Adobe CF wraps them in an event-handler exception. The page itself stays minimal and now nameswheels.log. Existing apps must copy the change into their ownpublic/Application.cfc: replace$renderMinimalError()and add$logStartupFailure(),$startupFailureCause(),$startupFailureField()and$startupFailureFrame()from the currentwheels newtemplate, and passarguments.EventName ?: ""as the second argument at both$renderMinimalError()calls inonError()(#3671) whereclauses no longer split on an uppercaseAND/ORinside an identifier: a table-qualified condition such asORDER_AND_ITEMS.id = 1(or a paginatedfindAll()on such a model) resolved a bogus_ITEMS.idproperty and threwWheels.ColumnNotFound, and a condition starting with a property likeORDER_NOlost its leadingOR. Only standaloneAND/ORkeywords now separate conditions (#3675)wheels test --test-timeout=<seconds>sets the test run's time budget from the terminal. The documentedwheels test --timeout=<seconds>never reached the command there: LuCLI's own global--timeoutoption takes it (a non-integer is rejected by LuCLI, and a valid value was silently swallowed), so onlyWHEELS_TEST_TIMEOUTworked. MCP clients keep passingtimeout; precedence is the explicit value, thenWHEELS_TEST_TIMEOUT, then 900, and a value that is not a positive number warns and falls back as before (#3678)wheels testagain finds the project's own running server when the project directory's name differs from thenamein itslucee.json(worktrees, clones into a differently named directory, editedlucee.jsonnames). The server registry now resolves the name the way LuCLI registers it —lucee.json'sname, falling back to the directory basename — and also accepts any registration whose.project-pathpoints at the project.wheels start's "already running" / "registered to a different project" checks use the same resolution (#3679)wheels stopnow forwards--name=<server>,--config=<file>and--alltowheels server stop(the space form--name <server>works too); it previously dropped them, so a project with more than one registered server could not be stopped withwheels stop(#3680)- An immediate
wheels testre-run after a timed-out run no longer collides with the abandoned run still executing server-side. The core (/wheels/core/tests) and app (/wheels/app/tests) runners used to treat any request arriving mid-run as a re-entrant sub-request and skip their shared lock, so two suites ran at once against the same test database. An overlapping run now waits on the lock and then runs normally. Only sub-requests that echo the in-progress run's token (wheelsTestRun) skip the lock. The CLI's timeout message now says the abandoned run is still executing (#3683) - On H2 (the 1.3.172 driver Lucee bundles), a query builder
offset()withoutlimit()returned rows in the wrong order. The builder asks for "no limit" asLIMIT 2147483647, and H2 1.3.172 mis-sorts onceLIMIT + OFFSETpasses the 32-bit maximum. The H2 adapter now clamps the limit so the sum stays at that maximum, which also covers an explicit hugelimit()with an offset (#3684) - The repository's demo app now keeps its H2 database under its own root (
db/h2/wheels-dev.h2.db). Its datasource used the relative pathjdbc:h2:file:./db/h2/wheels-dev, which resolves against the server process's working directory: for a LuCLI server that is the Lucee Express install, so every demo-app server on the machine, from any checkout or worktree, shared (and locked) one file at~/.wheels/express/<lucee-version>/db/h2/wheels-dev.h2.db(or~/.lucli/express/<lucee-version>/db/h2/for servers started with a plainluclibinary). To keep existing demo data, stop the demo servers and copy that file intodb/h2/of the checkout that should own it; otherwise the demo database starts empty. Apps created withwheels newalready anchor their SQLite/H2 paths to the app root and are unaffected (#3689) insertAll()works on BoxLang with Oracle. BoxLang asks the driver for generated keys on every INSERT, and for theINSERT ... SELECTstatement that Oracle bulk inserts use, the driver failed withORA-17009: Closed statement(inside a transaction, onlyError closing connection: Connection is closed). The statement now runs as an anonymous PL/SQL block, so no key request is made (#3715)- With Oracle on BoxLang and Adobe 2023, model finders returned datetime columns as raw
oracle.sql.TIMESTAMPdriver objects, whichDateFormat(),DateCompare(), output, concatenation andSerializeJSON()could not handle. They are now CFML dates, to the millisecond, as on every other engine and database (#3719) wheels generate controller Pages index(andwheels generate view) now write the plain view template forindex/show/new/editactions instead of the scaffold CRUD views, which linked to resource routes such asnewPagethat a plain controller never registers — soGET /pages/indexthrewWheels.RouteNotFoundand returned 404. Scaffolds still get the CRUD views (#3722)wheels generate model <Name> --belongsTo=<Parent>(CLI,galias and the MCPgeneratetool) now adds the foreign-key column to the generated migration, asscaffoldandapi-resourcealready did —<parent>_idwhenuseUnderscoreReferenceColumns=true(thewheels newdefault),<parent>Idotherwise. The migration previously had no FK column, so the association's default foreign key named a missing column andwheels seedfailed. A column you list yourself, in either shape, is not duplicated (#3723)- Wheels now initializes each
request.wheelskey when it is missing, even ifrequest.wheelsalready exists. On Adobe CF 2025 the engine raises an error while loading the app's datasources during startup; the app'sonErrorthen createdrequest.wheels, Wheels skipped initializing the request, and the first request after a restart failed withElement WHEELS.HTTPREQUESTDATA.HEADERS is undefined in REQUEST. WhatonErrorrecorded is kept (#3726) - On Adobe ColdFusion 2025 without the optional graphqlclient package, the first request after a start no longer fails with HTTP 500 because of the engine's pre-start "The graphqlclient package is not installed." notice.
onError()recognizes that exact notice, logs it towheels.log, and lets the request continue. A clean first request also needs the framework fix for #3730. Existing apps on Adobe 2025 that do not install graphqlclient must add the guard to their ownpublic/Application.cfcby pasting the block at the top ofonError()from the current template. Installing the package (cfpm install graphqlclient) is the alternative (#3726) - The first request after a cold start no longer fails with HTTP 500 on Adobe ColdFusion 2023 and 2025.
onApplicationStartfailed withjava.util.EmptyStackExceptionatNeoPageContext.popSuperScopewhile creating the internalwheels.Publiccomponent. The cause is an Adobe engine bug, triggered the first time a Global mixin creates a Global subclass. Wheels now works around it with no application changes. Health checks and readiness retries usually hid it by absorbing that first request. (#3730) - Background jobs:
enqueue(),enqueueIn()andenqueueAt()now persist the job class with the casing of the file on disk. On a case-insensitive filesystem (macOS, Windows, or a macOS checkout bind-mounted into Docker) a miscasednew app.jobs.sendwelcomeemailjob()resolves, and Adobe CF reported its metadata name with the caller's casing, so the row named a class a case-sensitive Linux worker could not instantiate (Wheels.JobClassNotFound). The name is now canonicalized segment by segment against the directory listing before it is written (#3731) - Migrator: a migration whose
up()throws is no longer silent.migrateTo()folds the failure into the string it returns rather than throwing, so the application-start auto-migrate path (autoMigrateDatabase) discarded it entirely — one unrunnable migration stalled every migration queued behind it while the app booted healthy and nothing was logged. The migrator now records the failed step (direction, version, file, engine message and detail) to thewheelslog at the point of failure, so every entry point sees it - Routing:
$cgiScope()now actually strips a leading/index.cfm/frompath_info. The cleanup discarded the result ofReplace(), so on servers that report the front controller in the path (e.g. IIS returning a blankcgi.path_info, where the fallback copiesrequest_urisuch as/index.cfm/users/list) the router receivedindex.cfm/users/listand matched no route. The strip is anchored to the leading segment and case-insensitive, so a later segment like/docs/myindex.cfm/xis left untouched. wheels destroy <model>no longer generates a drop-table migration that fails with "The parameter [name] to function [dropTable] is required": the template now emitsdropTable(name=...), andMigration.dropTable()acceptstableNameas an alias ofname, so migrations already generated by earlierwheels destroyruns now migrate without edits- The
down()of the migration fromwheels generate snippets soft-deleteno longer callsremoveIndex()with an unsupportedcolumnNamesargument; it passes the explicitindexNamethatup()creates linkTo(method="delete")now addsrel="nofollow"instead of the misspelledrel="no-follow", which crawlers do not recognise as a link type — so delete links were never actually marked as not-to-be-followed. A caller-suppliedrelis still preserved (e.g.rel="external nofollow")- The
wheels mcp wheelstool list no longer advertisesdbmigrate. It is the historical alias formigrate(likedfor destroy andgfor generate, which were already hidden), and it has nomcpToolSpecs()entry, so MCP clients saw it as a no-argument twin ofmigrate. The stdiotools/liston the pinned LuCLI runtime (0.6.1) now carries one entry per command, with the declared input schema on every ArgSpec-backed tool (#2963) - View helpers:
paginationLinks(prependOnFirst=false)no longer emits a mangled first item (e.g. a stray-item">1</li>) when the first page'sprependToPagemarkup differs in length from the raw argument — the current page withaddActiveClassToPrependedParent=true(which injectsactive), markup whose attributes are stripped by sanitization, orencode=truemarkup whose numeric entities are decoded. The helper now strips exactly the prefix it emitted for the first page, mirroring howappendOnLast=falsealready uses the sanitized suffix. - Pagination helpers:
params(string or struct) is no longer dropped whenpageNumberAsParam=false.nextPageLink(),previousPageLink(),firstPageLink(),lastPageLink(),pageNumberLinks()andpaginationNav()(includingviewStylepresets) now keep the query string on route-segment page links, sonextPageLink(route="postsPaged", pageNumberAsParam=false, params="sort=title")onposts/page/[page]renders/posts/page/3?sort=titleinstead of/posts/page/3, matching legacypaginationLinks(). - Query builder:
offset()is now applied. It was stored but never passed to the finder, somodel("User").orderBy("id").limit(10).offset(20).get()returned rows 1-10.get()/findAll()andfirst()/findOne()now emit SQLOFFSET(withlimit()as the row count, or every remaining row when no limit is set) and default to primary-key order when noorderBy()is given; on SQL Server the limit is clamped to the rows left after the offset so the last page is correct.count(),exists(),updateAll(),deleteAll(),findEach()andfindInBatches()ignore the offset.afterFindcallbacks now also run on query results fetched with an offset. - Routing: with
URLRewriting="Off",urlFor()/linkTo()no longer double-encode custom route variables that end up in the query string. Routeblogs/[slug]withurlFor(route="blog", slug="a b")now producesslug=a+b(the server receivesa b) instead ofslug=a%2Bb(the server receiveda+b); with HTML-attribute encoding (linkTo,startFormTag, …) the value is also HTML-encoded once instead of twice, which previously split the value on the&of the+entity. - Routing: typed constraint helpers (
whereNumber(),whereAlpha(),whereMatch(), etc.) chained after an unnamed route with optional segments now constrain every variant that route registers. Previously only the last-registered (shortest) variant was inspected, so.get(pattern="archive(/[year])", to="archive##index").whereNumber("year")constrained nothing and/archive/abcdispatched withyear="abc", and.get(pattern="posts/[id](.[format])", ...).whereNumber("id")left/posts/abc.jsonunconstrained. Named routes were unaffected. singularize()no longer mangles ordinary words ending ini:safari,spaghetti,piandapipreviously becamesafarus,spaghettus,pusandapus(andvirus/octopuslost their trailings) because the Latin-plural rule was written as the character class([octop|vir])i$instead of an alternation. The rule is now(octop|vir|cact|radi|foc)(us|i)$, sooctopi,viri,cacti,radiiandfocistill singularize to their-usforms.- Honor case-insensitive spec names as well as IDs in testSpecs filters; name filters no longer silently skip every spec.
wheels newscaffolds ship an up-to-dateCLAUDE.md: the template copy had drifted about 74 lines behinddocs/consumer-ai/CLAUDE.md(missing auth, storage, DI and seeding guidance) and wrongly saidwheels dbmigrate diffdoes not exist;ship-consumer-docs.sh checknow fails when the two copies differ- A model call made with
transaction = "none"(orfalse) that throws no longer disables transaction handling for the rest of the request.invokeWithTransaction()sets the request's open-transaction marker for those modes too, and a throw from the invoked method skipped the reset, so every latersave()/create()/update()in that request took the "already open" path and ran with no transaction, andtransaction = "rollback"stopped rolling back. The commit/rollback branch already reset the marker on errors (#3302); thenone/falsebranch now does the same - Model:
updateAll(instantiate=true, validate=false)now skips validations as documented. The instantiate path called each object'supdate()without forwardingvalidate, so validations always ran and records failing them were silently left unchanged (and excluded from the returned count). - Model:
upsertAll()no longer overwrites thecreatedAtof an existing row when a record conflicts onuniqueBy. The framework-stamped create timestamp was included in theON CONFLICT/ON DUPLICATE KEY/MERGEupdate clause, so every upsert that hit an existing row reset itscreatedAtto now; it is now only written on insert whileupdatedAtis still refreshed. AcreatedAtyou put in the records yourself is still written on conflict. H2'sMERGE ... KEYform rewrites every column and is unchanged. - Model validations: a leading
!in a compoundcondition/unlessexpression now negates only the clause it prefixes, matching CFML precedence (!binds tighter than&&, which binds tighter than||). Previously$evaluateConditionString()stripped the!and inverted the whole remaining expression, so!isNew() || this.force eq 1was evaluated as!(isNew() || this.force eq 1)and the validation ran or was skipped on the wrong records. A negated parenthesised group such as!(a || b)still negates the whole group - WebSocket upgrades to
/ws/<name>now reach lucee/extension-websocket (used by thewheels-websocketspackage at/ws/wheels) under thewheels newTomcat rewrite rules and thewheels deploy initDockerfile. Previously the front-controller catch-all rewrote the upgrade to/index.cfm/ws/...before Tomcat's WsFilter could match the endpoint, so the client got a Wheels 404 instead of a101. The new pass-through is gated on theUpgrade: websocketheader, so ordinary HTTP routes under/ws/keep reaching the router. Existing apps are not updated automatically —wheels startonly copies the template into a project that has norewrite.config, and it copies the template bundled with the CLI you run, so deleting the file regenerates these lines only from a CLI release that includes this fix (on 4.1.0 and earlier it regenerates the old rules). Add the two lines — theRewriteCondand the rule — by hand to your project-rootrewrite.config, just above the# Route everything else through the front controllercatch-all; apps deployed with a Dockerfile fromwheels deploy initneed the same two lines added to theprintfrule list before'RewriteRule ^/(.*)$ /index.cfm/$1 [L]':CommandBox/Undertow (RewriteCond %{HTTP:Upgrade} ^websocket$ [NC] RewriteRule ^/ws/.*$ - [L]urlrewrite.xml) is unchanged: on Undertow the extension accepts the upgrade but never runs the listener, so there is no working WebSocket path there yet (#3676, wheels-dev/wheels-websockets#1) - Migrations whose
up()does its work with rawqueryExecute()orcfquery, for example DDL against a second datasource, and then callsannounce()are recorded in the migrator versions table again. 4.1.0 treated them as announce-only and never recorded them, so they re-ran on every migrate, and a re-run that fails (such asCREATE TABLEon an existing table) stalled every migration behind it. Only a migration that keeps the inheritedNOT IMPLEMENTEDplaceholderup()/down()is left untracked now, and the migrator logs a warning when it skips one. After upgrading, a version 4.1.0 left unrecorded runs once more and is then recorded. If that re-run fails because its work is already applied, mark it as applied withwheels migrate pretend <version> --yes
Security
- The Wheels CLI could send state-changing commands, console code and the application's reload password to a local server it had not verified as the current project's own, for example another application listening on the project's configured port or on a common development port. Commands that change state or evaluate code (
migratewrite actions,seed,db reset,reload,console,jobs work,generate admin,coverage,test,browser test) now require the server this project started withwheels start(orwheels engines rustcfml start), and refuse otherwise, naming the port. The server is verified as that registered process, the only process listening on its port, and, for every request, the process that accepted the CLI's connection before anything is sent on it. Requests go to the address the server binds rather than resolvinglocalhost;consolenever falls back to probing ports; read-only commands that use an unverified server say so. The CLI now sends the reload password in anX-Wheels-Reload-Passwordrequest header instead of the URL query string, keeping it out of access and proxy logs.public/Application.cfcaccepts the header alongside the existing?password=parameter. Existing apps should copy the header-mapping block (theX-Wheels-Reload-Passwordblock near the top ofonRequestStart(), before the reload lock) from the current template into the same place in their ownpublic/Application.cfc; until they do, the CLI falls back to the query string for that project's own verified server only and prints a notice..envdetection now reads only a line-startPORTandWHEELS_RELOAD_PASSWORD/RELOAD_PASSWORD, not another key that ends the same way such asDB_PORT(GHSA-x3cm-2j3q-jgg4) redirectTo(url=...),redirectTo(back=true)andstartFormTag()'s CSRF-token check no longer accept an absolute or protocol-relative URL that puts the current host in front of another one as userinfo.$isSafeRedirectUrl()took the host as everything before the first:,/,?or#, so it readhttps://<your-host>:x@evil.com/as same-host. Browsers read<your-host>:xas credentials and go toevil.com. That let an open redirect through withallowExternalRedirects=false, and madestartFormTag()send the authenticity token to that host. The check now isolates the URL's authority (host and port) first and rejects any URL whose authority contains userinfo (@).