Skip to content

Wheels 4.1.1

Choose a tag to compare

@github-actions github-actions released this 28 Sep 17:01
6771c7e

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 true or false: --force=yes, --force=1, or any other value now fails with Wheels.InvalidArguments instead of being read loosely (a bare --force still means true, and --no-force false). Numeric options reject non-numbers, and a string option given as a bare flag (e.g. --to with 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.InvalidArguments naming the argument, instead of ignoring them. The global --offline flag is still accepted by every command (#2963)
  • deploy is no longer exposed as an MCP tool: it runs SSH, image pushes, and container restarts on remote hosts, so it stays CLI-only like new, start, and setup (#2963)
  • The MCP test tool now prints a warning when its timeout is 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> --offline now passes --offline through to wheels new, which skips its update check; before, generate consumed the flag and new reset it (#2963)
  • Upgrade note (4.0.x → 4.1.x) — validation condition/unless expressions now fail closed. A condition/unless string 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 throws Wheels.InvalidValidationCondition — an HTTP 500 on the save that triggers it. 4.0.x caught the same parse failure and returned false, which skipped the validation entirely: app authors should treat a 4.1 upgrade as the moment previously-inert validations start being enforced, and audit any condition/unless that 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) and condition="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 terminal wheels test --timeout=<seconds> reaches the test command; --test-timeout keeps 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 generate accepts an explicit dry-run=false (sent by MCP clients as --no-dry-run) instead of failing with Unknown flag, and a failed dry run no longer leaves later generate calls in the same MCP session writing nothing. wheels analyze rejects 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: routes format, seed mode (an unknown mode used to generate rows), test reporter, analyze target, destroy type (which also exits non-zero now), the db/packages/upgrade verbs, upgrade format, migrate action, and the generate/create types. MCP tool schemas advertise the valid values as a JSON Schema enum, from the same ArgSpec declaration the parser enforces (#2963)
  • wheels migrate diff on a missing model and a refused wheels migrate rename-system-tables now exit non-zero, and wheels test exits non-zero on any runner response without test counts (such as a missing-datasource error) (#2963)
  • migrate and create MCP tool calls now honour every advertised parameter. Both rebuilt argv from named keys in struct order, so {action: "info", "dry-run": false} failed as Unknown migration action: --no-dry-run, forget/pretend lost their version, diff lost its model, and {name, type} reached create as Unknown create type: --name=x. A later MCP call no longer stays offline because an earlier one passed offline, and a bare --name-style flag no longer binds the literal true as a positional value (#2963)
  • MCP tool calls now honour positional parameters. LuCLI's MCP server passes every tools/call argument by name, and ArgSpec.parse() bound positionals only from typed tokens, so advertised properties such as destroy's type/name and analyze's target were silently ignored. db, packages, and reload also advertise real input schemas now, built from the same ArgSpec their parsers use (#2963)
  • wheels test (and the MCP test tool) 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/unless expressions that combine clauses with &&/|| no longer throw Unsupported 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 idiomatic StructKeyExists(this, 'property') and the list predicates ListFind/ListFindNoCase/ListContains/ListContainsNoCase (so legacy conditions of the shape ListFind('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 thrown Wheels.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, so StructKeyExists(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 paginated index action reached through the catch-all wildcard() route (the default scaffolded routes) no longer throws Wheels.RouteNotFound — which rendered as a whole-page 404 in production, with nothing logged, because the route error path aborts. The ambient request.wheels.params.route name from the #942 fix is now adopted only when it resolves to a single named route; the wildcard name registers one candidate per method x pattern, and paginationLinks() 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 empty route is treated as "no route" instead of being passed to $findRoute(), which threw Wheels.RouteNotFound for the empty name and gave applications no way to suppress the ambient-route adoption in the meantime (#3638)
  • PackageLoader resolves a package's entry-point CFC deterministically instead of taking whichever *.cfc the directory listing returns first. A package with more than one root CFC now declares it with "main": "Sentry" in package.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)
  • PackageLoader now verifies that each package mapping alias it registers is actually resolvable by the engine (ExpandPath() probe) and reports the ones the engine ignored through getUnresolvedMappings(), plus one boot log line. Lucee ignores application.mappings written 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 .env value of numeric 1 is no longer coerced to the boolean true. The generator template, the bundled examples and the config dump / get settings / reload CLI commands all tested booleans with ==, which Lucee compares NUMERICALLY — "1.0" == "true" is true — so a numeric setting such as a sample rate of 1.0 arrived 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 twelve Variable HTMLEDITFORMAT is undefined errors on every adobe2025 leg, and a new structural guard (HtmlEditFormatGuardSpec) fails the suite if a direct BIF call reappears anywhere under vendor/wheels (#3645)
  • create() with an explicit primary key now works on SQL Server. model("Post").create(id = 41, ...) used to fail with Cannot 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 with SET IDENTITY_INSERT <table> ON before and OFF after, both guarded by a sys.identity_columns lookup 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 a java.util.Date, so IsDate() rejected it — RateLimiter's database-backed token bucket read every bucket as permanently empty or full, and JobWorker.getMonitorData() returned a non-date oldestPending. The new $normalizeDbTimestamp() helper accepts a CFML date, plain or boxed epoch milliseconds (Adobe + sqlite-jdbc), a java.util.Date, Oracle's oracle.sql.TIMESTAMP (bridged through timestampValue()) and fractional-second datetime strings, and replaces the SQLite-only inline epoch conversion in JobWorker (#3649)
  • insertAll() no longer fails on Oracle under Lucee with ORA-03048: SQL reserved word 'ROWID' is not syntactically valid following '... FROM dual RETURNING'. A cfquery result attribute makes Lucee request generated keys from the driver, and the Oracle driver implements that by appending RETURNING ROWID INTO ? to every INSERT, which Oracle rejects after the INSERT ... SELECT ... FROM dual UNION ALL bulk shape. Nothing on the bulk paths reads the result or a key, so insertAll() and upsertAll() now run their statements without one ($performQuery(..., $captureResult = false)); the Lucee + Oracle core suite goes from four bulkOperationsSpec errors 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 paginated findAll() 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 to wheels.log (type error) before rendering, walking RootCause/Cause so 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 names wheels.log. Existing apps must copy the change into their own public/Application.cfc: replace $renderMinimalError() and add $logStartupFailure(), $startupFailureCause(), $startupFailureField() and $startupFailureFrame() from the current wheels new template, and pass arguments.EventName ?: "" as the second argument at both $renderMinimalError() calls in onError() (#3671)
  • where clauses no longer split on an uppercase AND / OR inside an identifier: a table-qualified condition such as ORDER_AND_ITEMS.id = 1 (or a paginated findAll() on such a model) resolved a bogus _ITEMS.id property and threw Wheels.ColumnNotFound, and a condition starting with a property like ORDER_NO lost its leading OR. Only standalone AND / OR keywords now separate conditions (#3675)
  • wheels test --test-timeout=<seconds> sets the test run's time budget from the terminal. The documented wheels test --timeout=<seconds> never reached the command there: LuCLI's own global --timeout option takes it (a non-integer is rejected by LuCLI, and a valid value was silently swallowed), so only WHEELS_TEST_TIMEOUT worked. MCP clients keep passing timeout; precedence is the explicit value, then WHEELS_TEST_TIMEOUT, then 900, and a value that is not a positive number warns and falls back as before (#3678)
  • wheels test again finds the project's own running server when the project directory's name differs from the name in its lucee.json (worktrees, clones into a differently named directory, edited lucee.json names). The server registry now resolves the name the way LuCLI registers it — lucee.json's name, falling back to the directory basename — and also accepts any registration whose .project-path points at the project. wheels start's "already running" / "registered to a different project" checks use the same resolution (#3679)
  • wheels stop now forwards --name=<server>, --config=<file> and --all to wheels 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 with wheels stop (#3680)
  • An immediate wheels test re-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() without limit() returned rows in the wrong order. The builder asks for "no limit" as LIMIT 2147483647, and H2 1.3.172 mis-sorts once LIMIT + OFFSET passes the 32-bit maximum. The H2 adapter now clamps the limit so the sum stays at that maximum, which also covers an explicit huge limit() 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 path jdbc: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 plain lucli binary). To keep existing demo data, stop the demo servers and copy that file into db/h2/ of the checkout that should own it; otherwise the demo database starts empty. Apps created with wheels new already 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 the INSERT ... SELECT statement that Oracle bulk inserts use, the driver failed with ORA-17009: Closed statement (inside a transaction, only Error 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.TIMESTAMP driver objects, which DateFormat(), DateCompare(), output, concatenation and SerializeJSON() could not handle. They are now CFML dates, to the millisecond, as on every other engine and database (#3719)
  • wheels generate controller Pages index (and wheels generate view) now write the plain view template for index/show/new/edit actions instead of the scaffold CRUD views, which linked to resource routes such as newPage that a plain controller never registers — so GET /pages/index threw Wheels.RouteNotFound and returned 404. Scaffolds still get the CRUD views (#3722)
  • wheels generate model <Name> --belongsTo=<Parent> (CLI, g alias and the MCP generate tool) now adds the foreign-key column to the generated migration, as scaffold and api-resource already did — <parent>_id when useUnderscoreReferenceColumns=true (the wheels new default), <parent>Id otherwise. The migration previously had no FK column, so the association's default foreign key named a missing column and wheels seed failed. A column you list yourself, in either shape, is not duplicated (#3723)
  • Wheels now initializes each request.wheels key when it is missing, even if request.wheels already exists. On Adobe CF 2025 the engine raises an error while loading the app's datasources during startup; the app's onError then created request.wheels, Wheels skipped initializing the request, and the first request after a restart failed with Element WHEELS.HTTPREQUESTDATA.HEADERS is undefined in REQUEST. What onError recorded 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 to wheels.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 own public/Application.cfc by pasting the block at the top of onError() 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. onApplicationStart failed with java.util.EmptyStackException at NeoPageContext.popSuperScope while creating the internal wheels.Public component. 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() and enqueueAt() 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 miscased new 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 the wheels log at the point of failure, so every entry point sees it
  • Routing: $cgiScope() now actually strips a leading /index.cfm/ from path_info. The cleanup discarded the result of Replace(), so on servers that report the front controller in the path (e.g. IIS returning a blank cgi.path_info, where the fallback copies request_uri such as /index.cfm/users/list) the router received index.cfm/users/list and matched no route. The strip is anchored to the leading segment and case-insensitive, so a later segment like /docs/myindex.cfm/x is 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 emits dropTable(name=...), and Migration.dropTable() accepts tableName as an alias of name, so migrations already generated by earlier wheels destroy runs now migrate without edits
  • The down() of the migration from wheels generate snippets soft-delete no longer calls removeIndex() with an unsupported columnNames argument; it passes the explicit indexName that up() creates
  • linkTo(method="delete") now adds rel="nofollow" instead of the misspelled rel="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-supplied rel is still preserved (e.g. rel="external nofollow")
  • The wheels mcp wheels tool list no longer advertises dbmigrate. It is the historical alias for migrate (like d for destroy and g for generate, which were already hidden), and it has no mcpToolSpecs() entry, so MCP clients saw it as a no-argument twin of migrate. The stdio tools/list on 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's prependToPage markup differs in length from the raw argument — the current page with addActiveClassToPrependedParent=true (which injects active ), markup whose attributes are stripped by sanitization, or encode=true markup whose numeric entities are decoded. The helper now strips exactly the prefix it emitted for the first page, mirroring how appendOnLast=false already uses the sanitized suffix.
  • Pagination helpers: params (string or struct) is no longer dropped when pageNumberAsParam=false. nextPageLink(), previousPageLink(), firstPageLink(), lastPageLink(), pageNumberLinks() and paginationNav() (including viewStyle presets) now keep the query string on route-segment page links, so nextPageLink(route="postsPaged", pageNumberAsParam=false, params="sort=title") on posts/page/[page] renders /posts/page/3?sort=title instead of /posts/page/3, matching legacy paginationLinks().
  • Query builder: offset() is now applied. It was stored but never passed to the finder, so model("User").orderBy("id").limit(10).offset(20).get() returned rows 1-10. get()/findAll() and first()/findOne() now emit SQL OFFSET (with limit() as the row count, or every remaining row when no limit is set) and default to primary-key order when no orderBy() 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() and findInBatches() ignore the offset. afterFind callbacks 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. Route blogs/[slug] with urlFor(route="blog", slug="a b") now produces slug=a+b (the server receives a b) instead of slug=a%2Bb (the server received a+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 &#x2b; 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/abc dispatched with year="abc", and .get(pattern="posts/[id](.[format])", ...).whereNumber("id") left /posts/abc.json unconstrained. Named routes were unaffected.
  • singularize() no longer mangles ordinary words ending in i: safari, spaghetti, pi and api previously became safarus, spaghettus, pus and apus (and virus/octopus lost their trailing s) 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)$, so octopi, viri, cacti, radii and foci still singularize to their -us forms.
  • Honor case-insensitive spec names as well as IDs in testSpecs filters; name filters no longer silently skip every spec.
  • wheels new scaffolds ship an up-to-date CLAUDE.md: the template copy had drifted about 74 lines behind docs/consumer-ai/CLAUDE.md (missing auth, storage, DI and seeding guidance) and wrongly said wheels dbmigrate diff does not exist; ship-consumer-docs.sh check now fails when the two copies differ
  • A model call made with transaction = "none" (or false) 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 later save()/create()/update() in that request took the "already open" path and ran with no transaction, and transaction = "rollback" stopped rolling back. The commit/rollback branch already reset the marker on errors (#3302); the none/false branch now does the same
  • Model: updateAll(instantiate=true, validate=false) now skips validations as documented. The instantiate path called each object's update() without forwarding validate, so validations always ran and records failing them were silently left unchanged (and excluded from the returned count).
  • Model: upsertAll() no longer overwrites the createdAt of an existing row when a record conflicts on uniqueBy. The framework-stamped create timestamp was included in the ON CONFLICT / ON DUPLICATE KEY / MERGE update clause, so every upsert that hit an existing row reset its createdAt to now; it is now only written on insert while updatedAt is still refreshed. A createdAt you put in the records yourself is still written on conflict. H2's MERGE ... KEY form rewrites every column and is unchanged.
  • Model validations: a leading ! in a compound condition/unless expression 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 1 was 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 the wheels-websockets package at /ws/wheels) under the wheels new Tomcat rewrite rules and the wheels deploy init Dockerfile. 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 a 101. The new pass-through is gated on the Upgrade: websocket header, so ordinary HTTP routes under /ws/ keep reaching the router. Existing apps are not updated automatically — wheels start only copies the template into a project that has no rewrite.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 — the RewriteCond and the rule — by hand to your project-root rewrite.config, just above the # Route everything else through the front controller catch-all; apps deployed with a Dockerfile from wheels deploy init need the same two lines added to the printf rule list before 'RewriteRule ^/(.*)$ /index.cfm/$1 [L]':
    RewriteCond %{HTTP:Upgrade} ^websocket$ [NC]
    RewriteRule ^/ws/.*$ - [L]
    
    CommandBox/Undertow (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 raw queryExecute() or cfquery, for example DDL against a second datasource, and then calls announce() 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 as CREATE TABLE on an existing table) stalled every migration behind it. Only a migration that keeps the inherited NOT IMPLEMENTED placeholder up()/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 with wheels 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 (migrate write actions, seed, db reset, reload, console, jobs work, generate admin, coverage, test, browser test) now require the server this project started with wheels start (or wheels 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 resolving localhost; console never falls back to probing ports; read-only commands that use an unverified server say so. The CLI now sends the reload password in an X-Wheels-Reload-Password request header instead of the URL query string, keeping it out of access and proxy logs. public/Application.cfc accepts the header alongside the existing ?password= parameter. Existing apps should copy the header-mapping block (the X-Wheels-Reload-Password block near the top of onRequestStart(), before the reload lock) from the current template into the same place in their own public/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. .env detection now reads only a line-start PORT and WHEELS_RELOAD_PASSWORD/RELOAD_PASSWORD, not another key that ends the same way such as DB_PORT (GHSA-x3cm-2j3q-jgg4)
  • redirectTo(url=...), redirectTo(back=true) and startFormTag()'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 read https://<your-host>:x@evil.com/ as same-host. Browsers read <your-host>:x as credentials and go to evil.com. That let an open redirect through with allowExternalRedirects=false, and made startFormTag() 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 (@).