You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
transaction="savepoint" runs invokeWithTransaction() or any persistence method as a nested unit: inside an open transaction it rolls back only its own writes when the method returns false or throws, and the outer transaction carries on. With no transaction open it behaves like commit. Records in a rolled-back unit get afterRollback right away and never afterCommit. Each unit runs a primary-key read before setting its savepoint, because Lucee drops a savepoint set before a transaction's first query. Inside a raw CFML transaction {} block a unit also nests as a savepoint on Lucee, BoxLang and Adobe ColdFusion; on RustCFML, which can't see the raw block, it joins it like commit (#3958, #4068)
A transactionIsolation setting sets the default isolation level of the transactions Wheels opens for model writes and invokeWithTransaction() (default read_committed, unchanged). Set it to read_uncommitted, repeatable_read or serializable, or to "" to send no isolation attribute and use the engine's or driver's default. An isolation argument on the call still wins (#4059)
On SQL Server 2016+ (database compatibility level 130 or higher), a query whose IN lists would bind more than the 2097 parameters SQL Server accepts now runs: its largest lists of integers, plain decimals, strings and uniqueidentifiers are bound as one parameter each and expanded on the server with STRING_SPLIT, every value still bound. Queries within the limit keep their SQL. Any other list (dates, times, float, real, bit, text or binary values, or numbers such as +5, .5 or 1E5), and databases below compatibility level 130, keep the Wheels.TooManyParameters error, whose details now say which lists SQL Server runs as one parameter; long date and time lists are tracked in #4318 (#4103)
Added $snapshotRoutes(), $restoreRoutes(), and $clearRoutes() to wheels.WheelsTest so tests that redefine the route table can snapshot and restore every route-derived structure (routes, staticRoutes, namedRoutePositions, the urlFor caches, and the router index) in one call, instead of hand-rolling a partial save/restore that leaks stale route state into later specs. (#4194)
wheels migrate unlock reports who holds the migration lock: the host, owner id, how long it has held it and when its lease expires. While a live instance holds it, the command exits non-zero and names wheels migrate unlock --force, which removes the lock and prints what it removed. The migrate MCP tool has the same unlock action, and it only removes the lock when force is true. In the framework, migrationLockStatus() returns the same details and releaseMigrationLock(force) removes the lock: without force, only an expired lease. The Wheels.MigrationLockTimeout error now points at the command (#4209).
$testClient().get() accepts an optional timeout (seconds, default 30) that bounds the underlying HTTP request, so a request that may hang fails the asserting test case rather than timing out the whole test run (#4219)
On SQL Server 2016 and later, a statement whose date or timestamp IN lists push it past the parameter limit now runs: each long list binds as one STRING_SPLIT parameter, its values compared the way the same list is when it fits (in the column's own type on a DATETIME, SMALLDATETIME or TIME column, otherwise as datetime2(7) / date), so it matches exactly the same rows. Values must be written as yyyy-mm-dd with an optional time, HH:mm with optional seconds for a time, or be CFML dates; dates in other forms still throw Wheels.TooManyParameters (#4318)
Background jobs can call heartbeat() from inside perform() to signal that they are still running. Once a job has heartbeated, it is reclaimed when its heartbeats stop for its heartbeat grace, even inside its timeout: the job's own this.heartbeatGrace, or else set(jobsHeartbeatGraceSeconds = n) (default 300; 0 turns it off; values below 30 are raised to 30). Heartbeat at least every grace / 3. The stale-job reaper measures from the latest heartbeat (COALESCE(heartbeatAt, updatedAt)), so a long job that keeps heartbeating isn't reaped and run a second time. A job that never heartbeats is reclaimed only after its claim window (its timeout plus the larger of 60 seconds and its timeout), as before. A stopped heartbeat isn't proof the worker died: a perform() stuck in a long call that can't heartbeat is reclaimed too, so heartbeat around such calls or raise the grace. heartbeat() never extends a job's timeout, which stays the hard cap on one attempt. An exclusive job's heartbeat renews its lease by its grace window, so a job that stops heartbeating loses its lease on the same clock it's reclaimed on. A heartbeat after the job's claim was reaped and re-issued throws Wheels.Job.Fenced, telling the old attempt to stop. Each new claim clears the previous attempt's heartbeat. wheels_jobs gains a nullable heartbeatAt column, added to existing tables automatically (never inside a transaction); without it, heartbeat() renews updatedAt instead. (#4477, #4502)
Jobs gain this.idempotent (default true). After a reap, a job is retried only when its class is idempotent, which is today's behaviour. A class with this.idempotent = false is not re-run: the reaped attempt ends with the new status interrupted, counted by queueStats() and JobWorker.getStats() and purgeable with purge(status = "interrupted"). (#4477)
enqueue(), enqueueIn() and enqueueAt() accept uniqueKey: only one job is written per key, including when two enqueues race each other. A duplicate writes nothing and returns {enqueued: false, duplicate: true, status: "duplicate"} with the existing job's id; a key stays taken while its row exists, until it is purged. Every enqueue result now includes enqueued and duplicate. wheels_jobs gains a nullable uniqueKey column (VARCHAR(255)) with a unique index (filtered to non-NULL keys on SQL Server). It is added to existing tables automatically under the migration lock, taken without waiting, and backfilled from id; servers still on the previous version keep enqueuing during a rolling upgrade. enqueue(uniqueKey=...) throws Wheels.Job.UniqueKeyUnavailable when the column can't be added, and Wheels.Job.InvalidUniqueKey for a key over 255 characters. (#4478)
Background jobs can now be made exclusive: set this.exclusive = true and a second run of that job class can't start on any server while one is running. A job can instead set this.concurrencyKey = "...", or define concurrencyKeyFor(struct data) (which takes precedence), to share one lease among the jobs with the same key (for example one run per account). A job that finds its lease busy waits as pending without using up an attempt, and counts as skipped. The lease is held in a new wheels_job_locks table, created on first use, through wheels.LeaseLock, the cross-server lease the migrator already used, now extracted for reuse. Exclusivity is only as strong as the lease: it lasts the job's claim timeout plus a grace period, and a run that outlives it (for example one stuck in a blocked database call) can overlap a second run; that is logged and counted as a lost lease. Servers sharing the table need synchronised clocks. (#4479)
Background jobs gain per-host control for apps running on more than one server.
set(jobsMaxConcurrentPerHost = n) caps how many jobs one server runs at once. The default 0 means no cap, which is today's behaviour. The cap is enforced in the worker, so wheels jobs work, processNext() and the new JobRunner.tick() all respect it. The count and the claim happen under one lock per host, so two polls can't both take the last slot.
new wheels.JobRunner().drain(expiresInSeconds) / .resume() stop a server from starting new jobs while the jobs it is already running finish (for deploys). Reaping continues, and an expired drain lifts itself.
JobRunner.tick(queues, maxConcurrent, timeout, maxJobs) is one round for a scheduled task: it registers the server in the new wheels_job_hosts table (auto-created, never inside a transaction), reaps, and runs up to maxJobs jobs unless the server is draining or full. JobRunner.status() reports the server's running jobs, cap and drain.
A server is identified by the host name recorded in wheels_jobs.claimedBy. Set set(jobsHostName = "...") when several app servers share one machine, so each gets its own cap and drain. processNext() results gain capped and draining. (#4480)
wheels jobs drain stops the running server from starting new background jobs while the jobs it is already running finish, and wheels jobs resume lifts it. For deploys, wheels jobs drain --wait blocks until the server has no running jobs (up to 600 seconds, or --wait=<seconds>) and exits with an error if jobs are still running, so the server can be stopped safely. --expires=<seconds> sets when the drain lifts itself (default 3600; 0 = until resumed). wheels jobs status now also shows this server's running jobs, cap and drain. (#4480)
Recurring background jobs.
Defining schedules. Define schedules in config/schedules.cfm:
schedule("canary").job("CanaryJob").every(15, "minutes")
They are synced to a new wheels_job_schedules table at application start. Apps can also insert their own rows at runtime (e.g. from an admin page); the sync never changes those, and a code schedule removed from the file is disabled, not deleted.
Running them. Call new wheels.JobScheduler().enqueueDue() regularly on one or every server. Each due slot is enqueued once, with uniqueKey = "<schedule>:<slot time, UTC>", however many servers call it.
Expressions. Cron takes the standard 5 fields with lists, ranges, steps and month and day names, the @hourly to @yearly aliases, and an IANA time zone. A local time skipped by a DST change runs at the next valid instant, and a repeated one runs once. Intervals are minutes or hours, aligned to UTC.
Missed slots.catchUp("latest") (default) runs only the newest missed slot within catchUpWindow() (default 3600 seconds); catchUp("none") runs only a current one. A slot never runs twice.
(#4481)
A token-gated jobs tick route, /wheels/jobs/tick, lets an engine scheduled task, cron job or systemd timer run background jobs inside the app server without a worker process. Each call runs one JobRunner.tick() (reap dead workers' jobs, then run jobs up to the per-host cap) and returns a JSON summary; an overlapping call on the same server is skipped. The route is off (404) unless set(jobsRunnerToken = "...") is set; every call must send the token in the X-Wheels-Jobs-Token header (compared in constant time), or as ?token= only with set(jobsRunnerTokenInQuery = true) for schedulers that can only set a URL (the token then ends up in access logs). Requests carrying a forwarding header (X-Forwarded-For, Forwarded, X-Real-IP, CF-Connecting-IP) are refused: the token, not the client's address, is the boundary. It works with enablePublicComponent = false. wheels jobs tick runs one tick from the command line. (#4482)
Background jobs gained lifecycle hooks. beforePerform(data) and afterPerform(data, result) run with perform(), under its timeout, and a throw from either fails the attempt. onSuccess(result) runs once the job is recorded as completed, and onFailure(error, attempt, isFinal) after each failed attempt, with isFinal = true when the job won't be tried again (including when a crashed worker's last attempt is reaped). Both are best-effort: a throw is logged and never changes the job's outcome. set(jobsOnFailure = "component.path.method") adds an app-wide failure hook, called with one struct (jobId, jobClass, queue, attempt, maxRetries, isFinal, error) as its event argument; job data is never passed to it. perform()'s return value is now stored in a new wheels_jobs.result column (added automatically): simple values as-is, anything else as JSON, cut short at 4000 bytes with a ...[truncated] marker. (#4483)
wheels jobs install writes a migration that creates the background-job tables (wheels_jobs, wheels_job_hosts, wheels_job_schedules, wheels_job_locks), so an app can apply the jobs schema once, reviewed and under migration control, instead of having every server create and alter it at startup. The migration adopts tables Wheels already created automatically, adding only what they lack, and running it again changes nothing. Apps that use it set set(jobsAutoCreateTables = false), which stops the framework from creating or altering the job tables at runtime; a missing table then raises Wheels.Job.SchemaMissing from enqueue and from a worker's poll, with a pointer to wheels jobs install. Auto-create stays the default (jobsAutoCreateTables = true). The migration's down() drops the job tables, including ones it adopted, and every job in them. The job schema is now defined once (wheels.JobSchema), so the migration and auto-create build the same tables. (#4484)
Added afterCommit and afterRollback model callbacks. afterCommit(methods, on) runs its methods only after the database transaction actually commits — the right place for side effects that must not happen on a rollback (enqueue a job, send mail, bust a cache, call an external API). afterRollback(methods, on) runs when the transaction rolls back. Both cover create, update, and delete, support an optional on="create,update,delete" filter, and (for nested writes or an invokeWithTransaction block) fire once, together, when the outermost transaction resolves. With no transaction (transactionMode="none") afterCommit fires immediately after each write. A callback that throws runs after the commit, so the commit stands; the error is logged to wheels.log and propagated. These callbacks require a Wheels-managed transaction (transaction() / invokeWithTransaction): a write placed inside a raw CFML transaction {} block is skipped (with a one-time wheels.log warning) on Lucee and BoxLang, and is not detectable on Adobe CF or RustCFML — there the behaviour inside a raw transaction {} is left to the engine and is not guaranteed.
In the development environment with showErrorInformation on (its default there), an error response asked for as JSON (Accept: application/json or ?format=json) is now the structured payload the error page's Copy button builds: exception type and message, suggested action, location, source snippet and classified stack, plus statusCode. Accept: text/markdown (without application/json) or ?format=md returns the same content as Markdown. Both use the error page's status code and drop anything the request wrote before it failed. Testing, maintenance and production responses are unchanged. The payload's location now picks an app frame that is the throw site itself, as the error page does, and its JSON keys keep their case on every engine.
appCacheFetch(), appCacheRead(), appCacheWrite(), appCacheExists(), appCacheDelete() and appCacheClear() cache your own computed values in the application cache. appCacheFetch(key, callback, time) returns the cached value or computes, stores and returns it. They work in every environment. A cached false, 0 or "" counts as a hit. Keys are case-sensitive strings or a struct or array of values. Entries live in their own data category, so appCacheClear() never empties the action, page, partial or query caches. $addToCache() now returns whether it stored the value.
travelTo(moment, callback) on wheels.WheelsTest freezes the framework clock for the callback, so time-dependent behaviour is testable deterministically without sleeping. Framework code reads the clock through a $now() / $tick() seam that returns the live clock in production (no behaviour change) and the frozen instant under travelTo(): cache expiry and culling, the RateLimiter rate-limit windows, and model createdAt / updatedAt timestamps honour it. The override is request-scoped and restored in a catch-free finally, so it never leaks into another spec (#4387)
wheels deploy now mounts a top-level volumes: list on every app container (Kamal's format: host:container or host:container:ro|rw, a host path or a named volume), so a SQLite app can keep its database on the host across redeploys. Entries are validated, and accessory volumes: / directories: arguments are now quoted like the other docker arguments, so paths with spaces or special characters work. Because they are no longer passed through the remote shell unquoted, a ~ or $VAR in an accessory volume's host path is no longer expanded on the host: write the absolute path (for example /home/deploy/data instead of ~/data) (#4018)
wheels deploy can run the database migrations once per deploy, on one host. Add migrate: true (or migrate: {host: <host>, timeout: <seconds>}) to config/deploy.yml. The migrate host (default: the first host of the first role, which must be proxy-fronted) boots and cuts over first, with WHEELS_MIGRATE_ON_BOOT=true and a longer health-check timeout (default 300s). Every other app container gets WHEELS_MIGRATE_ON_BOOT=false, so hosts sharing a database no longer all migrate at start. A migration that fails stops the deploy before any host cuts over. Without the block nothing changes. Apps with only job/worker roles are refused for now (#4063).
New set(migrateOnBoot=true): migrate to the latest version when the application starts, and fail the start (Wheels.BootMigrationFailed, written to the wheels log) when a migration is still pending afterwards. A boolean WHEELS_MIGRATE_ON_BOOT environment variable overrides both migrateOnBoot and autoMigrateDatabase: true migrates strictly, false never migrates at start. Without it, autoMigrateDatabase behaves exactly as before (#4063).
escapeForLike(), a global string helper that escapes the LIKE wildcards (%, _, [) and the \ escape character in a search term, so a user's input is matched literally in a LIKE '...' ESCAPE '\' comparison instead of being read as wildcards (#4355)
$testClient().fromAddress(ip) sets the client address (REMOTE_ADDR) for subsequent requests, so per-client behaviour such as rate limiting and IP rules can be exercised in specs. In the isolated test application the address reaches middleware through the request context; it never changes the cgi scope and is ignored outside the test context (#4343)
forgetCachedQueries(), a public helper to drop the per-request finder cache that cacheQueriesDuringRequest fills: model("Post").forgetCachedQueries() clears one model's slot (and returns the model for chaining), forgetCachedQueries("Post") clears it by name from anywhere, and forgetCachedQueries(all = true) clears every model's. A bare call outside a model throws rather than silently wiping everything. Replaces the old advice to delete the reserved request.wheels["$queryCache"] key directly (#4364)
wheels framework install installs the CLI's bundled framework into vendor/wheels/ for an app that has none (for example one moving off CommandBox), creating vendor/ if needed and pinning box.json's wheels-core when present. It refuses when a framework is already installed (use wheels upgrade apply to replace one), and --to=<version> must match the bundled version (no downloads)
New controller helper isSafeRedirectUrl(redirectUrl) returns true when a URL stays on this site (a relative URL, or one on the current host), the same rule redirectTo(url=…) applies, so a return-to URL can be checked before it's stored or linked. Like every framework helper it can't be routed as an action: an app with a controller action named isSafeRedirectUrl must rename it.
enqueue(), enqueueIn() and enqueueAt() accept transactional=false, and a job class can set this.transactional = false: the job is then written after the outermost Wheels-managed transaction ends, whether it commits or rolls back, so failure notices and audit records survive a rollback. With no transaction open it's written immediately; the default (true) is unchanged. A raw transaction {} block and a request that ends with abort aren't covered
wheels jobs enqueue <JobName> adds a job to the queue from the command line: now, after --in=<seconds>, or at --at=<ISO 8601 time>, with --data (a JSON object), --queue and --priority. It accepts only a component under app/jobs/ (or a jobClassPrefixes path) that extends wheels.Job, checked before the class is loaded. Scheduling here is a one-off delayed run; there is no recurring schedule.
wheels jobs work --stop-when-empty exits once a poll finds no job ready to run, so one-shot batches from cron or CI finish. --max-jobs=N alone still waits for N jobs; combine the two to cap a batch.
The jobs tick route (/wheels/jobs/tick) takes its batch from settings: jobsRunnerTickMaxJobs (most jobs per call, 1-1000, default 1), jobsRunnerTickTimeout (seconds per job, 1-86400, default 300) and jobsRunnerTickQueues (comma list, default every queue); a fractional or out-of-range value falls back to the default. The defaults keep today's behaviour of one job per call. They are settings rather than request parameters, so a caller holding the token can't widen the work one request does.
LocalDisk storage driver: an opt-in resolveSymlinks config flag (default false) that adds a stricter containment check on top of the always-on lexical guard (#3912). When enabled, $resolve() canonicalises paths through the filesystem so a symlink planted under root that targets outside it is rejected with Wheels.Storage.InvalidKey instead of followed. The disk runs a one-time behavioural capability probe at construction and fails closed with Wheels.Storage.InvalidConfiguration on a runtime that can't resolve symlinks (the JVM-free RustCFML engine, or Windows without symlink privilege), rather than silently enabling an ineffective strict mode. Detection is a capability probe, never an engine-name check. (#4020)
belongsTo(polymorphic=true), hasMany(as=...) and hasOne(as=...) accept a foreignType argument naming the polymorphic type column, so an underscore-shaped schema (commentable_id / commentable_type, as t.references(polymorphic=true) writes it with useUnderscoreReferenceColumns) works when both foreignKey and foreignType are passed. The default stays <name>type
renderNotFound(message, type, extendedInfo) — a public controller helper for producing a 404 from app code: it sets the 404 status, shows the typed error in development (when showErrorInformation is on) and the onmissingtemplate.cfm page in production. Generated scaffolds now call it instead of the internal $throwErrorOrShow404Page, whose signature was never a public contract (that internal still works for apps generated by 4.1.2). It is a controller mixin (not a wheels.Global helper), so it is scoped to controllers and not added to models. Because it is a public controller helper it is now a reserved action name — an existing app with an action literally named renderNotFound would get Wheels.ActionNotAllowed instead of dispatching (no such action exists in the shipped examples or guides). An app that already defines its own renderNotFound controller helper transparently overrides this one, and generated scaffolds then call that helper with message=. (#3900)
Specs can assert how many SQL statements a call sends: assertQueries(count, callback), assertNoQueries(callback), assertQueriesMatch(pattern, callback, count), assertNoQueriesMatch(pattern, callback) and recordQueries(callback), in every wheels.WheelsTest spec. They count what the model layer sends (a query-cache hit sends nothing), take an optional dataSource filter, nest, return the callback's result, and list the statements on failure with each bound value as ?
Models report what their last successful save wrote: savedChanges(), hasSavedChange(property), savedChangeFrom(property) and savedChangedProperties(), the saved counterparts of allChanges(), hasChanged(), changedFrom() and changedProperties(), which a save resets. They're available from the save's after* callbacks on, and each afterCommit sees the save that queued it, so an afterCommit can act on what a commit changed (for example, announce a record once each time it ends up published) without a hand-kept flag. An afterRollback sees the save that was rolled back, after which the previous state is restored. reload() and clearChangeInformation() clear it
Job schedules (config/schedules.cfm, schedule()) now run from the normal job processing: every worker poll (wheels jobs work), processQueue() and JobRunner.tick() enqueue due schedule slots, at most once every jobsScheduleCheckSeconds (default 15; 0 turns it off), so no separate scheduler process is needed. Any number of servers can do it at once: each slot is still enqueued exactly once. An app without schedules is unaffected: the check only runs when the schedules table exists. JobRunner.tick() reports the slots it enqueued as scheduled.
New strictArguments setting reports an argument that a framework declaration doesn't know, instead of ignoring it: a typo or another framework's name such as foreign_key, orderby or a migration column's nullable. It checks associations, validations, callback registration, property(), table(), the finders (including dynamic finders), controller caches() / protectsFromForgery() / provides() / usesLayout() and migration column functions. "warn" (the development default) logs one wheels.log line per model or controller, function and argument, with a suggested name; "throw" raises Wheels.UnknownArgument; "off" (the default elsewhere) keeps the old behaviour
TestClient ($testClient()) sends the session's authenticity token to forgery-protected actions the way a browser does. It picks up the token from a page it fetched (the csrf-token meta tag from csrfMetaTags(), or a form's authenticityToken field) and sends it on POST, PUT, PATCH and DELETE as the X-CSRF-Token header and, on a form body, the authenticityToken field. The token is dropped when the session cookie changes, as at login, and a token or field the test passes itself wins. New methods: csrfToken(), fetchCsrfToken(path), withCsrfToken(token) and withoutCsrfToken().
withAdvisoryLock() now accepts an opt-in transaction = true argument that pins the lock acquire, the callback, and the release to one connection inside a transaction, so the lock genuinely covers the callback's own queries. On PostgreSQL the lock is transaction-scoped and auto-releases when the transaction ends; on MySQL and SQL Server it is a session lock released before the transaction closes. Supported on PostgreSQL, MySQL, and SQL Server; other databases throw rather than silently running session-scoped. The lock is released even when the callback ends the request with abort or redirectTo(). Recommended for short critical sections — a long-running callback holds its row locks and a pooled connection (and blocks PostgreSQL VACUUM) for the whole call. Defaults to false (the existing behaviour). (#4198)
wheels upgrade check checks the 4.1 → 4.2 items when an app below 4.2.0 targets 4.2.0 or later, and points at the 4.1 → 4.2 guide instead of reporting "no new breaking changes". Items that fail at runtime on 4.2 (an environment selected without WHEELS_ENV, an invalid joinType, condition/unless shapes 4.2 rejects, a renderNotFound route, tinyInt1isBit=false) fail the check; the rest (event templates, /vendor in .gitignore, returnAs="structs", X-Forwarded-Proto without trustProxyHeaders, and others) are advisory.
wheels upgrade check (4.1 → 4.2) adds three advisories: app code that reads persisted from a job result (4.2's enqueue() throws Wheels.Job.EnqueueFailed instead), a tests/runner.cfm that doesn't include the built-in wheels/tests/app-runner.cfm, and, as lines to review, where conditions, query-builder calls and dynamic finders that compare with an empty string, which 4.2 binds as '' instead of NULL. Each names the file and line, with comments stripped first.
wheels upgrade check names each framework fix that lives in the app-owned public/Application.cfc and is missing from the app's copy (session cookie settings, the onError DI guard and running-app message, the test-context include, the jBCrypt load path, the .env boolean parser, the reload helpers, header fallback and subfolder redirect), with the guide section for each. A missing onApplicationEnd() / onSessionEnd()arguments.applicationScope routing fails the check, because on Adobe ColdFusion it can leave the whole site erroring after a shutdown.
wheels lookup <query> looks up the Wheels API reference and the guides offline, and so does the new lookup MCP tool. A function name (findAll, model.findAll) prints its full entry: signature, parameters, example and link. A phrase (nested resources) lists the best-matching guide sections and functions, each with a link and an id that prints the whole entry. The CLI ships a docs index for its own framework version, built at release and snapshot time, so no network, server or project is needed.
Changed
On MySQL, t.boolean() / addColumn(columnType="boolean") now create a BIT(1) column instead of TINYINT(1). A DSN with tinyInt1isBit=false reported TINYINT(1) as a plain integer, so the model validated boolean properties as numbers ("is not a number"). Existing TINYINT(1) columns are unchanged: drop tinyInt1isBit=false from the DSN, or convert the column with changeColumn(columnType="boolean"). BIT(1) values read back as 1 on Lucee and Adobe and as true on BoxLang; both are truthy and compare equal in CFML (#3897)
On Oracle 23ai and later, t.boolean() / addColumn(columnType="boolean") now create a native BOOLEAN column instead of a numeric column, and the model maps a BOOLEAN column to cf_sql_bit. A numeric column reached the model as a number, so automatic validation rejected true ("is not a number"). Oracle releases before 23ai get NUMBER(1) (#4097). Existing numeric boolean columns are unchanged: Oracle cannot change the datatype of a non-empty column, so convert one by adding a BOOLEAN column, copying the values and dropping the old column. A BOOLEAN value reads back as 1 / 0 on Lucee and Adobe CF and as true / false on BoxLang (#3897)
An empty string in a where value now binds as a real empty string instead of SQL NULL, so count(where="col = ''") matches rows that store '', and col <> '' / col != '' match every other non-NULL row. Before, all three compared against NULL and matched nothing. This applies to findAll() / count() and the other finders, updateAll() / deleteAll()where conditions, dynamic finders such as findOneByName("") and the query builder, so updateAll() and deleteAll() with such a condition now change those rows. Use IS NULL / IS NOT NULL (or whereNull() / whereNotNull()) to match NULL. Lookups keyed on request values, such as findOne(where="token = '#params.token#'") or findOneByToken(params.token), now match a row that stores '' when the value is empty, so check required values first. The unquoted NULL keyword still binds as NULL; an empty value whose column binds as a number, date, time or boolean still matches nothing (SQLite binds its text datetimes as strings, so they follow the new behaviour); and Oracle, which stores '' as NULL, is unchanged (#4055)
A migration that uses a column type the database adapter doesn't map (for example t.column(columnName="payload", columnType="jsonb")) now fails at migrate time with Wheels.Migrator.UnsupportedColumnType, naming the type and the adapter and listing the supported types, instead of emitting a column with no type; that typeless column was a DDL syntax error on most databases, a raw missing-key error on Oracle, and on SQLite a column whose model failed to load (#4095)
Instances that share a database now run its migrations one at a time: migrateTo(), migrateToLatest(), migrateIndividual() and redoMigration() hold a cross-process lock, a lease row in the new wheels_migrator_locks table, on every supported database. An instance that waits re-reads which migrations are pending once it has the lock, so it doesn't run them again. It gives up after migrationLockTimeout (default 300 seconds) with Wheels.MigrationLockTimeout, naming the host holding the lock. The lease (migrationLockLease, default 3600 seconds) is renewed before each migration step, and a holder that died frees the lock when its lease runs out; a single migration step that runs longer than the lease can lose the lock to another instance (Wheels.MigrationLockLost). To clear a stuck lock at once, run wheels migrate unlock --force or delete its row from wheels_migrator_locks (#4134)
singularize() returns psychoanalysis for psychoanalyses instead of the misspelled psychoanalyasis; other -ses words such as bases, theses and analyses are unchanged (#4187)
singularize("databases") returns database instead of databasis, so a name such as Databases or userDatabases now singularizes to Database / userDatabase. An app that relied on the old databasis output, for example a model class or table named after it, needs to rename it. That's unlikely, but check before upgrading. Other -ases words (bases, cases, phases, purchases, vases) are unchanged (#4189)
On MySQL and PostgreSQL, withAdvisoryLock() on a tenant (non-shared) model takes its lock on the active tenant's datasource instead of the application's default one, like the model's queries, SQL Server and withAdvisoryLock(transaction = true). Two tenants on different databases using the same lock name no longer exclude each other, and no longer wait for each other inside one application (on MySQL, whose lock names are server-wide, tenant databases on the same MySQL server still share them); for one lock across all tenants, call withAdvisoryLock() through a shared model. The lock queries also build their connection options like other model queries, leaving out an empty username and password (#4223)
On SQL Server, an =, <>, IN or NOT IN condition on a DATETIME or SMALLDATETIME column now compares in the column's own type. A value with a fraction of a second, such as createdAt = '2026-01-02 10:00:00.123' on a timestamps() column, matched no row, because every engine sends the value as DATETIME2(7) while the column stores 1/300-second (or one-minute) steps. An = or IN condition with such a value can now match the row that holds it, and a <> or NOT IN condition now leaves that row out, as SQL Server does with a literal: createdAt <> '2026-01-02 10:00:00.001' used to return a row stored as 10:00:00.000 and now doesn't, because .001 rounds to .000 in a DATETIME. Whole-second values, ranges (<, >=, BETWEEN) and DATETIME2 / DATE columns compare as before (#4326)
Soft-deleting a record no longer destroys or unlinks dependent= children that a restore would need. Children whose model has a soft-delete column are soft-deleted with it, as before. Other children are left alone: dependent="delete" / "deleteAll" no longer hard-deletes them and dependent="remove" / "removeAll" no longer sets their foreign keys to NULL. On a soft delete, dependent= now applies to them only on a real delete (#4370)
delete(), deleteAll(), deleteOne() and deleteByKey() with softDelete=false are always real deletes. They now also remove rows that were already soft-deleted, and the dependent= children of a deleted record, without includeSoftDeletes=true. An explicit includeSoftDeletes=false no longer keeps them. With instantiate=true those rows now run their delete callbacks too (#4371)
reload() throws Wheels.RecordNotFound when the object's row no longer exists, instead of silently blanking every property. Uncaught in a controller action, that renders the 404 page. An object with no key (unsaved, or saved on a database that returns no generated key, such as Oracle) is left as it is, where it used to have every property blanked (#4372)
When a Wheels-managed transaction rolls back because its method threw, afterRollback callbacks now run after the transaction block has ended, as they already did for a rollback without an exception. A callback's database writes on this path are kept instead of being discarded with the transaction, and a callback that throws doesn't replace the method's exception
App test runs keep the runner's 30-minute request timeout: the tests/populate.cfm that wheels new creates no longer lowers it to 300 seconds, and the runner restores its own limit after an app's populate.cfm runs. A run that is stopped before it finishes reports an error (and says when the request timeout stopped it) instead of 0 passed, 0 failed, 0 errors.
wheels browser install now runs wheels browser setup (an alias, as wheels packages install is of add). It used to print that LuCLI intercepts the command and it won't reach Wheels, then do nothing; it does reach Wheels, since LuCLI intercepts install only as the first argument.
Browser specs that do not run no longer count as passed. Without Playwright (or in CI without WHEELS_BROWSER_CI_ENABLE), each browserDescribe() spec is reported as skipped, with the reason. When Playwright is installed but the browser cannot start, each one is reported as an error with the launch message, unless WHEELS_BROWSER_SKIP_LAUNCH_FAILURES=true asks to skip them. wheels test now counts skipped specs (3 passed, 1 skipped, not green) and lists each skip reason.
insertAll() and upsertAll() now honour their transaction argument like save(). With the default (commit), all of a call's batches run in one transaction, so a large import is all-or-nothing and a failing batch no longer leaves the earlier batches behind. Pass transaction="none" for the previous per-batch commits; rollback keeps nothing
The CLAUDE.md that ships with apps (from wheels new, the starter app and the core package) is now short enough for AI coding tools that cap or skip the middle of long files: conventions, common mistakes, testing and a topic index, under 8 KB (it was over 32 KB). The reference topics (models, routing, views, caching, middleware, DI, auth, storage, packages, migrations, jobs, SSE) moved unchanged to .ai/<topic>.md, and AGENTS.md tells agents to open a topic file when the task reaches it. Existing apps can copy CLAUDE.md, AGENTS.md and .ai/ from a new wheels new app. The build check now enforces the size budget and that every topic file is linked from the index. (#3960)
Global helpers (app/views/helpers.cfm) are now available to a controller once Wheels initializes it, rather than as soon as the object exists. A controller built directly with new or CreateObject(), or code in a controller's own pseudo-constructor (outside its functions), no longer sees them. Call global helpers from config() or an action instead.
When config/settings.cfm sets dataSourceName and not coreTestDataSourceName, coreTestDataSourceName now defaults to that dataSourceName instead of a datasource named after the folder of the app's front controller. An app that ran the framework test suite on a datasource with that folder's name must now set coreTestDataSourceName to it explicitly.
When a run of the framework's own test suite (/wheels/core/tests) would use the app's primary datasource, it now needs <datasource>_test and ignores allowTestsAgainstPrimaryDatasource: without the test datasource the run is refused and wheels.log notes that the setting was ignored, because the suite's populate.cfm drops and recreates tables. An explicit useTestDB=false still runs it on the primary datasource. Runs that pick another datasource (?db=, or a coreTestDataSourceName that isn't the primary) are unchanged, and app test runs keep honouring the setting. (#4254)
From Wheels 4.2, CSRF protection accepts a valid X-CSRF-Token header on any non-GET request. Earlier versions also required X-Requested-With: XMLHttpRequest, which Turbo and fetch() don't send. When a request carries both a form field and a header, it passes if either holds a valid token, as in Rails. startFormTag() and buttonTo() gain authenticityToken=false to leave the hidden field out. Use it for every form in cached markup that other visitors receive, and supply the token at request time from csrfMetaTags(); the caching guide shows how (#3959)
Authenticity tokens are now compared exactly on every engine: the submitted value must be the whole session token (#3959)
Breaking:wheels deploy now requires an accessory port: to name a bind address (the rule Kamal's unreleased main adopted): "<address>:<host port>:<container port>" with an IPv4 or bracketed IPv6 address, optionally ending in /tcp, /udp or /sctp (e.g. "10.0.0.20:5432:5432" or "127.0.0.1:5432:5432"). A bare port: 5432 or "5432:5432" now fails validation with a message naming the accessory; before, it went to docker run --publish as written and published on a random host port or on every interface. The published value is also quoted like the other docker arguments (#4067)
Migrations: an empty default on a column that is not string-like (bigInteger, binary, uniqueidentifier, integer, decimal, float, date…) now means "no default". It renders as DEFAULT NULL when the column allows null and as no DEFAULT clause on a NOT NULL column, in createTable(), addColumn() and changeColumn() alike. Before, bigInteger and uniqueidentifier produced a bare DEFAULT (a syntax error on every database for NOT NULL columns, and on PostgreSQL in every case), binary produced DEFAULT '' (rejected by MySQL), and DEFAULT NULL NOT NULL was rejected by MySQL.
Breaking:findAll(returnAs="structs") (and "struct") now returns an array of structs, as documented, instead of a struct keyed by row number ({1: {...}, 2: {...}}). It serializes to a JSON array, and ArrayLen() works on it. Code that indexes rows (rows[1]) keeps working. Code that called StructCount(), StructKeyList() or StructKeyArray() on the result, or looped over its keys (for (key in rows) { rows[key] }), must switch to ArrayLen() and for (row in rows). findInBatches(returnAs="structs") batches change the same way. (#4003)
findOne(returnAs="struct") and findByKey(returnAs="struct") return the record's struct instead of a {1: {...}} wrapper, and findEach(returnAs="struct") now calls its callback for each record (it used to process nothing). With no match they return an empty struct, as before, and so does the query builder's findOne(returnAs="struct") after offset(), including an offset past the end, which used to return false (#4003)
wheels generate auth --strategy=token indexes the apiTokenDigest column it looks every request's token up by, and its next steps say how to create the first account and how to protect actions. The app's .ai/auth.md gains a "Token APIs" section with a seed for the first account and a filter that requires a bearer token.
wheels generate migration still writes a blank migration, but a name that reads like a column change now gets a next step: AddEmailToUsers (or add_email_to_users) points to wheels generate property User email:string, which writes the addColumn() for you, and RemoveXFromY shows the removeColumn() call. Its usage example is now BackfillUserSlugs.
Guide links in framework and CLI messages (the plugin deprecation notices, the wheels.Test deprecation, the /wheels/mcp deprecation, wheels upgrade check, wheels mcp setup and the install hints) are built from the running version, so a 4.1 app links to the 4.1 guides instead of always the 4.0 ones. Links never point at a version whose guides haven't been published yet, and fall back to the latest guides for a dev build. (#3931)
The background-job stale-processing reaper now reaps each job on the timeout of the worker that claimed it, not the polling worker's. The claiming worker records its timeout in a new wheels_jobs.claimTimeout column (added to the table automatically, including to tables created before this change — probed on every $ensureJobTable, never a cached flag), and checkTimeouts builds each row's grace window from that value. Workers configured with different timeout values can now share a queue — a short-timeout worker won't reap a job a longer-timeout worker is still legitimately running. A row with no recorded claimTimeout (claimed before this change, or on a database where the column can't be added) falls back to the polling worker's timeout, so nothing breaks. Builds on #3888/#3984. (#3989)
Job schedules: a cron step on a single value (N/S) now runs from N to the end of the field, as in Vixie cron and cronie. 5/1 in the minute field was minute 5 alone and is now 5-59. In day-of-week, N/S now runs through 7 (Sunday), so 1/2 adds Sunday, and 7/2 (Sunday) is accepted instead of refused as a reversed range. 5/15, N-M/S and */S are unchanged.
A job enqueued inside a Wheels-managed transaction on another datasource than the job store's (a tenant datasource, or a model with its own dataSource()) is now written to the job store when that transaction commits, and dropped if it rolls back. Before, depending on the engine, such a job could outlive a rolled-back transaction (BoxLang), be written to the tenant's database where no worker reads it (RustCFML), or not be written at all (Adobe ColdFusion). enqueue() returns {status: "deferred", deferred: true, persisted: false} in that case. On the application's own datasource, a job still joins the open transaction.
enqueue(), enqueueIn() and enqueueAt() throw Wheels.Job.EnqueueFailed when the job cannot be written to the job store, instead of returning persisted: false, which callers rarely checked, so failed enqueues went unnoticed. One cause is enqueueing inside a raw transaction {} block on another datasource (a tenant's), which some engines refuse; inside a Wheels-managed transaction the write now waits for the commit. If an enqueue is best-effort in your app, wrap it in try / catch (Wheels.Job.EnqueueFailed e).
Background-job timestamps (runAt, createdAt, updatedAt, completedAt, failedAt, heartbeatAt, and the host registry's times) are now stored in UTC, read from the database's own clock, so every server that shares the job tables uses one clock: a server whose clock runs ahead or behind no longer reaps a live job early or picks up a delayed job late. The database's UTC time is read at most once a minute per application (about one-second precision); if a read fails the last good reading is kept. Timestamps are built by the database from integers, so servers in time zones with daylight saving are handled across the clock changes. H2 and SQLite use the server's own clock (both are single-host databases). H2 requires the JVM to run on UTC: H2 1.4 does timestamp arithmetic in the JVM's time zone. The API stays in local time: enqueueAt() takes a local date/time, and JobRunner.status() and the queue stats report local times. Upgrading: on SQL Server, MySQL, PostgreSQL, CockroachDB, Oracle and H2, rows written before this release hold the server's local time, so on servers that don't run in UTC either upgrade once the queue is empty (no pending or processing jobs; otherwise a delayed job runs early or late by the offset, and on a server behind UTC an in-flight job looks stale and is reclaimed), or set this.convertLocalOffsetMinutes in the wheels jobs install migration to the old servers' offset from UTC to convert existing rows once (in one transaction; running it again changes nothing). SQLite rows and servers already running in UTC need neither.
The Wheels CLI's runtime is LuCLI 0.6.2.2. wheels start and wheels server run now listen on 127.0.0.1, so a dev server is reachable only from your own machine. To reach it from a phone, a VM or another computer, start it with --host 0.0.0.0, or set "bindAddress": "0.0.0.0" in lucee.json; the startup output then warns that the server is reachable from your network. JMX monitoring (monitoring.enabled) listens on the same address, and LuCLI refuses to start unauthenticated JMX on a non-loopback address. Clients that resolve localhost only to ::1 need http://127.0.0.1:<port>/
The Wheels CLI's runtime is LuCLI 0.6.2.3. wheels <command> --env=<name> (and --envfile) now reach the command instead of being dropped; wheels start --dry-run no longer writes a default lucee.json and its preview shows --name; wheels --timing cfml '…' (a root option before a root subcommand) runs the subcommand; REPL history and settings.json stay inside the CLI's home instead of leaking into ~/.lucli; run <file>.cfm no longer prints the built-in variables setup; hints name wheels rather than lucli. Over MCP, generate type=app is now refused as intended: LuCLI tells the Wheels module that a call came from an MCP tool (see #3980).
The stdio MCP server no longer lists the create tool (hidden like new, so an MCP call to it is refused), and generate no longer advertises app as a type choice. A client that ignores the advertised schema can still send generate with type=app; refusing that server-side is tracked in #3980. create's advertised type+name schema also never matched the new options it forwards (such as port). wheels create app and wheels generate app on the command line are unchanged (#3910)
wheels generate model / scaffold now treat columns as required by default: an unmarked name:type column is created allowNull=false and added to validatesPresenceOf, so the generated model and migration agree. Mark a column nullable with name:type:optional (excluded from validatesPresenceOf), or give it a default with name:type=value (also excluded — the default fills an absence); the two combine as name:type=value:optional. A belongsTo foreign-key column is required by default. Previously every column was added to validatesPresenceOf even though the migration made it nullable, so a freshly generated model rejected any row that left a blank column. :optional uses the word rather than ? because zsh (the macOS default shell) treats a bare ? as a glob.
On MySQL, t.bigInteger() now creates a signed BIGINT, like every other database, instead of BIGINT UNSIGNED, so the column accepts negative values and validates as an integer (it used to bind as a decimal and accept fractions). Existing columns are unchanged, and changeColumn() keeps a column's current signedness. t.bigInteger() and t.integer() take a new unsigned = true option (MySQL only; ignored elsewhere) for a foreign key to an existing unsigned id, which MySQL otherwise rejects as incompatible (#4121)
wheels new apps now commit vendor/: the generated .gitignore no longer ignores it, so a clone, CI run or image build has the framework (vendor/wheels/) and every package added with wheels packages add. Before, a fresh clone had no framework and no command to restore it. In an existing app, remove the /vendor/ line from .gitignore and commit vendor/.
config/routes.cfm in a new app (wheels new) declares the root route before .wildcard(), with the wildcard last, as the routing docs recommend. Existing apps are unaffected; the root route matches either way.
wheels new (and wheels create app) now writes .mcp.json and .opencode.json in the new app, the files wheels setup agents writes, so an AI assistant opening the app finds the Wheels MCP server without a second command. Pass --no-agents to skip them. If they can't be written, wheels new warns and still creates the app.
On Oracle, new t.integer(), t.references() and id columns are created as NUMBER(10), new t.bigInteger() columns as NUMBER(19), and new t.boolean() columns before Oracle 23ai as NUMBER(1), instead of a bare NUMBER. They bind and validate as integers, so a new integer column no longer accepts a fractional value. Existing bare NUMBER columns keep their current typing, and changeColumn() without precision keeps a column's current precision, so changing a default or allowNull never re-types it (Oracle cannot narrow a populated column); the migrations guide shows how to convert one (#4097)
Breaking: an absolute URL's http/https scheme (urlFor(onlyPath=false), asset URLs, anything through $prependUrl) now honours the client-controlled X-Forwarded-Proto header only when set(trustProxyHeaders=true) is enabled — matching isSecure(). In 4.1.x the header set the scheme regardless of the opt-in, so an app not behind a trusted proxy could be induced to emit https:// absolute URLs from a spoofed header. The real socket TLS state (SERVER_PORT_SECURE) still always wins, unconditionally. Apps behind a trusted TLS-terminating reverse proxy must set set(trustProxyHeaders=true) before upgrading, or their absolute URLs will fall back to http://. The one-time deprecation warning added in 4.1.2 (#3835) is removed. (#3836)
Production error pages (showErrorInformation off, the default outside development) now return the correct HTTP status for Wheels errors — 404 for a client-triggerable Wheels.*NotFound / ActionNotAllowed, 403 for NotAuthorized / an invalid CSRF token, 406 for FormatNotAcceptable — instead of 500 for every error. The status classification previously ran only on the development (showErrorInformation on) rendering path (follows #3075, which fixed the same mapping for development)
Test runs no longer use the app's primary datasource through a project tests/runner.cfm: a runner of your own, including one copied from an older Wheels release, now gets the same <datasource>_test requirement as the built-in runner. The run uses <datasource>_test, or is refused unless useTestDB=false (wheels test --no-test-db) asks for the primary datasource.
A date passed to the query builder (where(), whereBetween(), whereIn()) or a dynamic finder (findOneByPublishedAt(date)) now compares as a date on SQLite. It used to reach the SQL as its CFML literal ({ts '...'}), which never matched a stored date, so the query silently returned no rows. Other databases already parsed that literal; they now receive the plain date text too.
The starter app's public/Application.cfc is synced with the app template, picking up the template's current request, error-page and session-cookie handling and the jBCrypt load path.
The wheels-starter-app release zip now includes the framework in vendor/wheels/ and runs on the Wheels CLI with an embedded SQLite database: unzip, copy .env.example to .env and set its passwords, then wheels start, wheels migrate latest and wheels reload. Under CommandBox (box server start) it still uses H2, with no box install step.
A test run that switches the datasource in the live application scope, because the app's public/Application.cfc lacks the vendor/wheels/events/testcontext.cfm include (or WHEELS_ENV isn't development or testing), now logs a warning to wheels.log once per run: until the run ends, other requests to the app read and write the test database. The warning names the fix, and the wheels upgrade check advisory for the missing include now says the same
wheels upgrade apply now parks the old framework at .wheels/backups/wheels.bak-<timestamp>/ in the project instead of vendor/wheels.bak-<timestamp>/. Inside vendor/, the backup was loaded as a package and logged a skipped-package error on every boot, and it was committed along with vendor/. The backups directory carries its own .gitignore. The output names the new location.
wheels upgrade check names the file it checked when it reports missing code, for example public/Application.cfc (no occurrences found), instead of a bare (no occurrences found).
A failed verifies() that declares no handler and no redirect now ends the request with HTTP 400 instead of a silent 200 with an empty text/html body — the declared precondition was not met. A JSON-format request receives a {"error":"Verification failed"} body; any other format receives an empty body (like a bare status). Verifications that declare a handler or redirect arguments are unchanged.
Removed
The "Convert dot to format parameter" rule is removed from the shipped urlrewrite.xml files (the app, the wheels new template and the examples). Its regex had doubled backslashes, so it never matched a normal URL; format suffixes such as /posts.json keep working, because the router reads them from the path. Existing apps can delete the rule from their own public/urlrewrite.xml (#4132)
Performance
The RustCFML engine adapter now pins stringIndexIsLinear() to false explicitly, so the WHERE-clause literal masker always takes the linear char-array path on RustCFML. Previously this relied on the Base adapter's java.lang.String capability probe failing there; because RustCFML already shims part of the java.* surface, a future build that shimmed java.lang.String would have flipped the probe and silently reverted RustCFML to the O(n²) index scan. The explicit override removes that risk (follow-up to #3903)
Binding a long string value in a where string, a finder or the query builder no longer slows down sharply with the value's length: the WHERE literal scanner now jumps between quotes instead of reading one character at a time. On RustCFML a long value is linear again; a long run of doubled quotes or a very large IN list is still super-linear there because of the engine's own string indexing (#3903)
The WHERE-clause literal scanner ($maskWhereLiterals, run by every dynamic finder, key finder, chainable query-builder call and uniqueness validation) is now linear on every engine. It previously walked the string one character at a time with Mid(), which costs O(index) on RustCFML, so a long bound value — or one with many quotes, or a large IN list — was O(n²): a 30,000-character value took ~620ms and 100,000 characters ~22s. It now reads the string once into a character array and walks that with O(1) indexing on the engines whose REMatch round-trips the input faithfully (Lucee, Adobe, RustCFML), and falls back to a single-pass Find()-jump scan on engines where it does not (BoxLang drops whitespace matches). The character-array path builds its short ODBC-date lookahead window inline rather than passing the whole array to a helper, because Adobe CF copies an array argument by value on every call, which would reintroduce O(n²) there. Such values now bind in linear time on every supported engine (#3903)
Creating a controller instance, which happens on every request, no longer mixes the framework's controller and view methods in one at a time. Each controller class works out once which methods to add and which super<name> aliases to create, and every later instance applies that result with two StructAppend calls. Measured per instance in the same process: 4.8 ms → 1.4 ms on RustCFML (the CI-pinned v0.693.0), 2.3 ms → 0.9 ms on Adobe ColdFusion 2023, 1.7 ms → 0.8 ms on Adobe ColdFusion 2025, 0.58 ms → 0.33 ms on Lucee 7 and 4.7 ms → 3.6 ms on BoxLang. Development keeps the old per-method path, so controller methods edited there still take effect without a reload; the new cacheControllerIntegration setting controls it (#4149)
pluralize() and singularize() cache their results per application, so a repeated word no longer rebuilds the irregular and uncountable lists and the rule table, or runs the rule regexes. Measured per call: 86 µs → 5 µs on RustCFML, about 300 µs → 10 µs on Adobe ColdFusion 2023 / 2025, 152 µs → 12 µs on BoxLang and 25 µs → 5 µs on Lucee 7. The cache is cleared when set() changes uncountables or irregulars, notices a word added to or removed from those settings directly (change an existing irregular through set()), is skipped when the current tenant overrides them, keeps a case variant of a word separate, caches only words of up to 100 characters and holds at most 2,000 entries (#4150)
linkTo() and URLFor() do less work per call on JVM engines (Lucee, Adobe, BoxLang): URLFor skips a redundant argument-normalisation pass when linkTo already resolved the arguments, route-variable substitution uses literal string operations instead of a regular expression per variable, and $tag resolves the HTML-attribute-encoding setting once per tag rather than once per attribute. The generated output is byte-for-byte unchanged. (#4151)
Creating any model object or controller no longer calls GetMetadata(this) while the object is being constructed. The memo of include-injected functions to promote to this always ended up under one entry (wheels.Global) on every supported engine, so it now uses that key directly. On RustCFML the call cost about 225 µs per object; model new() went from 1.30 ms to 1.13 ms there, and from 0.31 ms to 0.25 ms on Lucee 7 (#4174)
An app without plugins no longer pays for plugin mixins on every model and controller instance: $initializeMixins now returns early when the component's class has no mixins, instead of copying the whole variables scope into variables.core for nothing. new() and each row of a returnAs="objects" finder are about 12% faster on Lucee 7 and 8% faster on RustCFML (#4382)
Route matching scans only the routes that can match a request: those allowing its HTTP method whose first two path segments match the request's, plus routes keyed less specifically, in declaration order. Which route matches is unchanged. On RustCFML, whose regex cache holds 256 patterns before v0.711 and 1,024 from v0.711, this also keeps an app with more routes than that from recompiling a regex per route on every dynamic-route request.
Fixed
A bare true or false in a finder where string (where="isActive = true") is now bound as 1 / 0 instead of failing with "cfqueryparam cannot be determined", and the query builder and dynamic finders (where("isActive", true), findAllByIsActive(true)) now work against native boolean columns such as PostgreSQL's boolean (#3896)
An IN list longer than 1000 values (whereIn(), whereNotIn() or a hand-written IN (...)) now runs on Oracle 19c and earlier, which accept at most 1000 values per list (ORA-01795): Wheels splits it into parenthesised groups, OR-joined for IN and AND-joined for NOT IN, with every value still bound. On SQL Server, which binds at most 2100 parameters per request (2097 once the driver has taken its own), a query that would bind more than 2097 now fails before it runs with Wheels.TooManyParameters, naming the limit and the count, instead of the driver's "too many parameters" error (#3906)
A long integer IN list (whereIn("id", keys) or where="id IN (1,2,...)" with a few thousand values) no longer throws java.lang.StackOverflowError while the where string is parsed (#3907)
On Oracle, comparing a date or timestamp column with a value written into the SQL text (parameterize=false), such as where="createdAt > {ts '2000-01-01 00:00:00'}" or where="createdAt > '2000-01-01'", now returns the matching rows: an ISO value is written as an ANSI TIMESTAMP '...' / DATE '...' literal, which reads the same under any session date format, instead of a quoted string that fails with ORA-01843 (#4084)
A nested-properties struct key outside the range of a SMALLINT or TINYINT primary key (for example 40000 for a SQL Server SMALLINT key) is now treated as a new row, as it already was for INTEGER keys, instead of being looked up with findByKey() and rejected at bind time on Adobe ColdFusion. Each adapter gives the range for its column types: SQL Server's TINYINT is 0 to 255, and MySQL covers signed and UNSIGNED columns (#4087)
On SQLite, t.date(), t.datetime(), t.time() and t.timestamp() now declare DATE / DATETIME / TIME columns instead of TEXT, and the model validates columns with those declared types as dates. Automatic validations used to see a string and accept a value like "not a date". Values are still stored as ISO-8601 text. A declared DATE or TIME column now binds as text too, instead of being stored by the driver as epoch milliseconds. Existing TEXT date columns are unchanged and keep the column-name check; convert one with changeColumn(columnType="datetime"). A column declared DATE or TIME outside the migrator may hold old epoch-millisecond values: convert them first, as the 4.1 to 4.2 upgrade guide shows (#4093)
The channelDatabaseAdapter.poll() no longer returns events out of publish order when two events share a createdAt timestamp — common on SQL Server, MySQL, and Oracle, whose datetime columns round to coarse ticks, and possible anywhere on a same-millisecond publish. Events now carry a database-native monotonic sequence (SQLite uses its built-in rowid; an unrecognised database falls back to createdAt + id), poll() orders by it, and lastEventId resumes strictly after the referenced event — which also stops a same-tick event published before it from being re-delivered, and falls back to the since window when the referenced event has already been cleaned up. Existing wheels_events tables gain the column automatically (#4099)
The Postgres/CockroachDB readiness probe for the wheels_events monotonic seq column now scopes its information_schema.columns lookup to the current schema (AND table_schema = current_schema()), so a same-named wheels_events table in another schema of the same database can no longer make the probe read the wrong table's column metadata and misreport readiness (follow-up to #4106)
wheels <command> --help prints only the command's summary line and its Usage: / Examples: sections. Internal notes no longer appear in the help for reload, map, upgrade and packages, wheels coverage --help and wheels engines --help print a one-line summary, and Usage/Examples lines are indented.
A DECIMAL / NUMERIC value with more than 15 significant digits is now stored and matched in a where exactly on PostgreSQL, SQL Server, MySQL and CockroachDB under Lucee and BoxLang, which bound it through a double and so wrote or compared a different number; such a value now binds in each database's exact form, and values with up to 15 significant digits keep their existing bind (#4172)
pluralize() no longer throws Index -1 out of bounds on BoxLang for words ending in -ex, -lf or -rf (vertex, index, half, shelf, wolf), including model names that derive their table name from them. Output on Lucee and Adobe ColdFusion is unchanged (#4185)
Fixed $testClient() and BrowserTest base-URL auto-detection so a loopback test request reaches the running server when the host port is mapped to a different internal port (e.g. inside a container). When a mapping is detected the base URL now uses the server's local listener — its scheme and port, via the loopback address — and is byte-for-byte unchanged otherwise. Explicit testClientBaseUrl / browserTestBaseUrl overrides still take precedence. (#4195)
withAdvisoryLock() now keeps a second caller out on Lucee and BoxLang with MySQL or PostgreSQL. The database lock belongs to the pooled session that took it, and a second caller in the same application could borrow that idle session, take the lock too, and leave it held after both finished. Callers in one application now wait on an application-server lock first, and the wait for the database lock gets only the time left of timeout, so the whole call waits about timeout at most. The release is verified against the session recorded when the lock was taken: retried for up to 5 seconds when it ran on another session, then reported with Wheels.AdvisoryLockReleaseFailed naming the lock. Another server taking the lock once ours is gone is not reported as a failure (#4197)
wheels packages help and messages no longer say wheels packages install doesn't work. install is an alias of add, and the help now says so; add stays the documented verb.
On SQL Server, withAdvisoryLock() works outside a transaction. It failed with "The statement or function must be executed in the context of a user transaction", because sp_getapplock took the lock for a transaction; the lock now belongs to the database session, as on MySQL and PostgreSQL, with the same check that the release freed it (#4220)
wheels docs fetch gives the bundle's checksum download 30 s instead of 300 s (the bundle keeps 300 s), and the CLI's server-detection commands (lsof, netstat) are stopped after 15 s instead of waiting indefinitely (#4232)
On SQL Server, a condition on a TIME column works on every engine. It errored on Lucee ("The data types time and datetime are incompatible") and BoxLang, and on Adobe ColdFusion it dropped the value's fraction of a second, so only whole-second values matched. A long time list past SQL Server's parameter limit now runs too, split like a long date list (#4327)
An object whose row is soft-deleted behaves consistently (#4372):
After a soft delete(), the object's deletedAt holds the timestamp written. It goes back to its earlier value if the delete is vetoed or rolled back, so a retry works.
A second soft delete(), or one from a stale copy of a row soft-deleted elsewhere, returns false and changes nothing. It no longer rewrites deletedAt, and it no longer soft-deletes the dependents of a row it didn't delete.
reload() reloads a soft-deleted row instead of blanking every property.
order="table.column" (and order="table.column DESC") is now quoted the same way as order="column", so a table or column whose name is a reserved word works in the qualified form too. The table and column are matched, case-insensitively, against the model and its included associations and written under their real names; a qualifier that isn't one of those tables, such as an alias, is still passed through as written (#4374).
wheels upgrade check dates the renderPage() / renderPageToString() rename correctly: it happened in 2.0 (to renderView() and renderView(returnAs="string")), not 4.0. It also now flags a route that targets an action named isSafeRedirectUrl, which 4.2 made a framework helper: a request for that action gets Wheels.ActionNotAllowed (#4376).
wheels migrate diff on SQLite no longer reports DATETIME, DATE and TIME columns as changed to string, which made --write produce a migration converting them to strings (#4405)
The all-models wheels migrate diff covers every model file, not only the models a request had already loaded (#4406)
wheels migrate diff --write names its migration with the same 14-digit version as wheels generate migration, moving on a second while that version is taken (#4419)
wheels db status and wheels db version --detailed show when each migration was applied. The migrator stored the time, but the status report always left it blank, so the CLI printed - / applied unknown (#4407)
wheels test --core now reports the runner's own error (for example a missing core-test datasource) instead of "no test bundles ran for this scope", and the --db-on-app-suite warning names the real --test-db flag instead of a non-existent --useTestDB (#4408)
wheels browser <unknown>, wheels browser test without Playwright installed, wheels setup agents outside a project, and wheels generate snippets <unknown> now exit non-zero instead of printing a refusal and exiting 0, so scripts and CI can detect the failure (#4409)
The console's /routes command no longer fails with a syntax error; it lists routes as pattern -> controller#action (#4410)
wheels start no longer changes lucee.json when it has nothing to start. Starting a server that is already running only reports it; it used to move the shutdown port on every repeat. --dry-run passes --port to LuCLI's dry run instead of writing it, and skips the registry clean-up, JDBC bundles, rewrite config and start token. -p N / -p=N work like --port=N, so the shutdown port is moved off the chosen port. --dry-run with --engine=rustcfml is refused, where it used to start the server (#4411)
wheels start --env=<name> / -e <name> use the lucee.json environment profile, as --environment=<name> already did. LuCLI takes --env / -e as its own root option, so wheels start now forwards the value (#4412)
wheels new --setup-h2 no longer leaves SQLite datasource entries in lucee.json; the embedded-database choice in config/app.cfm and lucee.json now agree (#4413)
wheels generate controller <Name> with no actions now writes an empty controller instead of an index() stub with no matching view, which 500s at runtime (#4414)
wheels deploy build push honours builder.arch (--platform, default linux/amd64, so an image built on an arm64 workstation runs on amd64 hosts), builder.args (--build-arg per entry) and builder.remote (builds on the service's buildx builder, which build create now creates on that endpoint). The image is labelled service=<service>. builder.arch takes a list or a comma-separated string, and an entry that isn't a plain platform name (letters, digits, ., _, /, -) fails with Wheels.Deploy.InvalidInput (#4415)
wheels deploy app logs without --container tails each role's newest running container instead of emitting a docker logs with no container. wheels deploy prune images removes every unused image of the service (--all), not only dangling ones, matched by the label build push now sets; images built before this release carry no label (#4416)
A space-separated flag value works like the = form: --release v8 no longer reads v8 as the subcommand, and init --service myapp2 no longer falls back to the directory name. When several space-form values can't be told apart, or a value flag has no value, the command asks for --flag=value instead of guessing.
An unknown --role fails with DeployAppCli.UnknownRole and names the roles in deploy.yml, instead of doing nothing and exiting 0.
deploy config prints ssh.port and proxy.app_port as whole numbers (22, not 22.0).
rollback without a version and exec / server exec without a command throw the typed DeployMainCli.MissingVersion / DeployServerCli.MissingCommand.
CLI help and hints match the commands. wheels coverage --help lists --top and --[no-]test-db. wheels destroy --help says --force is what makes it delete (without it, destroy only lists). wheels new --help gives the lowercased app name as the datasource default. The wheels packages synopsis shows remove <name> --yes. The seed-data snippet hint says to move the files into app/db/ before running wheels seed (#4418)
The per-request finder cache (cacheQueriesDuringRequest) is now cleared across every model on any ORM write and on a transaction rollback, so a cached include= query no longer returns a joined model's pre-write columns — or a rolled-back transaction's phantom rows — for the rest of the request (#4429)
On RustCFML, with flashStorage="cookie", a flash message stored with flashInsert() can be read with flash() in the same request. RustCFML reads the flash cookie back as the attribute struct it was assigned, rather than its value; the flash reader now uses the value. On every engine, an empty flash cookie now reads as an empty flash instead of one with an empty action entry (#4433)
The all-models wheels migrate diff also covers models in subfolders of the model path, named in dot notation (admin.User) as model() takes them (#4434)
wheels generate snippets seed-data writes app/db/seeds.cfm and app/db/seeds/development.cfm, where wheels seed reads them, instead of app/snippets/; existing seed files are kept unless --force is given (#4434)
The dependency injector no longer throws a spurious Wheels.DI.CircularDependency when two of a request's cfthreads resolve the same transient (or request-scoped) service at the same time; the circular-dependency guard is now keyed per thread while keeping its per-request scope (#4448)
A .cfm page in a subfolder of public/ no longer fails with can't find component [wheels.events.EventMethods]. public/Application.cfc built its mappings, and config/app.cfm its SQLite and H2 paths, with expandPath("../..."), which resolves against the requested page's directory. The wheels new template now builds them from this.wheels.projectRoot, derived from Application.cfc's own path. Existing apps make the same edit: see the 4.1 to 4.2 upgrade guide (#4470)
Job.processQueue() now runs jobs through the job worker, so the in-app path the guide documents is as safe as wheels jobs work. Every call first reaps jobs a dead worker left in processing, which it never did before. Each job is claimed with its own class's timeout recorded as claimTimeout, so a server polling with a shorter timeout no longer reaps it while it runs. Jobs still run with their own timeout, as before. The new optional processQueue(timeout = n) caps it. queue accepts a comma-delimited list. (#4475)
A background job whose claim was reaped as stale can no longer overwrite the attempt that replaced it. Every claim now writes a fresh wheels_jobs.claimToken (and the claiming host to claimedBy, both added to existing tables automatically), and a worker's completion, retry or failure applies only while the row still carries its own token. Before, a stalled worker that finished after its job was requeued and claimed again matched the new attempt's processing row: it marked the job completed (or requeued it) while the second attempt was still running, and that attempt's own result then matched nothing. A fenced result is discarded and logged as Wheels.Job.Fenced. JobWorker.processNext() returns fenced: true and processQueue() reports a fenced count. Covers both JobWorker and processQueue(). Fencing holds once every worker sharing the table runs this version. (#4476)
When the wheels_jobs.uniqueKey upgrade can't finish, the warning and the Wheels.Job.UniqueKeyUnavailable error now say which step failed (adding the column, the backfill, or building the unique index) and give the manual fix for the database in use. On SQL Server below compatibility level 100, where the filtered unique index can't be built, they name the level and the real options: raise it, or create a plain unique index once every server runs 4.2 or later. Previously both messages advised the filtered index, which fails the same way. (#4489)
On RustCFML, converting epoch timestamps to app-local time no longer goes wrong by the host's UTC offset when the engine runs outside UTC. The jobs clock (enqueueAt(), JobRunner.status(), queue stats) and other epoch-to-date reads now count from the epoch instant instead of local midnight on 1970-01-01. JVM engines were not affected (#4525).
On Adobe ColdFusion, a model write (create(), save(), update(), delete()) inside a raw transaction {} block no longer fails with "Nested cftransaction tag should specify same isolation level as the parent". When Adobe rejects the default read_committed level of a nested model transaction, the write now joins the outer block at its isolation level. An isolation you pass yourself is never dropped: if it differs from the outer block's, the call throws Wheels.TransactionIsolationMismatch explaining the fix (#4045)
On Adobe ColdFusion, Wheels now detects every model write inside a raw transaction {} block, not only one that is the block's first database work. afterCommit/afterRollback are skipped for those writes with the usual warning (instead of afterCommit firing for a write the block then rolls back). RustCFML still can't see a raw block (#4068)
The AGENTS.md that wheels new ships no longer tells assistants to prefer MCP only "when .mcp.json is configured", a file a new app doesn't have. It now says to prefer the Wheels MCP tools when the client exposes them, and how to enable them (wheels setup agents, or the config snippet by hand), with the wheels CLI until then (#3971)
wheels generate api-resource controllers:
answer 400 with a clear message when the body has no {"<model>": {...}} wrapper or is not valid JSON, instead of a 500;
answer 404 for a key that cannot exist (/api/products/abc) instead of a 500;
return the index as a JSON array of records instead of query columns;
quote their top-level keys, so they are lower-case on every engine (products, product, error) rather than upper-case on Lucee.
Existing generated controllers are unchanged; regenerate or copy the pattern (#3881)
wheels generate api-resource no longer adds its route inside another generator's marked block in config/routes.cfm (such as wheels generate auth's api namespace, which generate auth --force rewrites); it opens its own .namespace("api") block instead, and the route it adds to an existing block is indented inside it without moving that block's .end().
An association's joinType now accepts left and left outer as aliases of outer (all emit LEFT OUTER JOIN), and any other unknown value throws Wheels.InvalidJoinType when the association is registered. joinType="left" used to emit LEFT JOIN, which the nested-include grouping didn't recognise, so a nested include under it (include="classifications(tag)") was silently dropped and the query failed with an unknown column. The grouping now throws instead of dropping a nested join it can't place (#3936)
The wheels-base-template download ships the application-developer AI docs (CLAUDE.md, AGENTS.md, .ai/README.md), like the core and starter-app packages, instead of the framework maintainers' CLAUDE.md, .ai/ and .claude/ commands. Existing apps installed from the 4.x base template can replace CLAUDE.md, AGENTS.md and .ai/ with the copies from a new wheels new app and delete .claude/. (#3947)
A SQLite column declared BIGINT (or INT8) and an Oracle NUMBER(11..19, 0) column (such as NUMBER(19)) are now typed cf_sql_bigint, so their 64-bit values bind correctly on Adobe ColdFusion, and migrate diff no longer reports a spurious integer to biginteger change for a SQLite BIGINT column. Wheels' own t.bigInteger() still creates INTEGER on SQLite, which stays 32-bit (#4086)
wheels browser setup joins the Playwright jars with the platform's path separator, so the classpath it passes to Java works on Windows (;) as well as macOS and Linux (:).
wheels browser setup now launches the browser once before it reports ready, and exits non-zero when the install or the launch fails. On a host missing the OS libraries the browser needs (a bare Linux install), it prints Playwright's install-deps command for this install instead of saying "Browser testing ready." A launch that doesn't finish within 120 seconds is stopped, along with everything it started.
insertAll() and upsertAll() no longer fail on SQL Server for batches whose rows × columns exceed the parameter limit. They used to send 1000 rows per statement whatever the column count, so a 5-column write of more than about 400 rows threw Wheels.TooManyParameters before writing anything; batches are now sized to fit (at least one row per statement)
save(callbacks=false) and delete(callbacks=false) now skip afterCommit and afterRollback too, like every other callback. They used to queue them. With callbacks on, a save that changes nothing still queues afterCommit
t.char() now creates a CHAR column on SQLite, H2, MySQL and Oracle. Those adapters had no char type mapping, so the column was created with no type, and on SQLite every model on the table then failed to load (#4092)
The background-job table upgrade no longer re-fires the wheels_jobs.claimTimeoutALTER on every poll when the database user lacks ALTER privilege. wheels jobs work builds a fresh JobWorker each poll, so a blocked ALTER was retried every cycle (log noise plus a failed statement each time). A failed ALTER is now recorded in an application-scoped, time-bounded memo and the retry backs off for a window (re-probed and re-attempted once it elapses, so a later GRANT or manual ALTER is still picked up) — never a permanent "gave up" flag. The column-existence probe still runs every call, and a missing/NULLclaimTimeout continues to fall back to the polling worker's timeout. (#4071)
clearChangeInformation(property = "x") no longer resets change tracking for every other property. It reset the whole change baseline and refilled only x, so every untouched property read as changed, was written by the next save(), and over-reported in savedChanges() / changedProperties(). It now refreshes only the named property's baseline, leaving the rest intact (#4381)
wheels upgrade apply prints its report once; it used to appear twice in a terminal.
wheels new --help (and wheels help new) lists the options and examples; it used to print only a one-line summary. A bare wheels new prints the same help.
A source checkout of the CLI no longer prints the raw @build.version@ token. wheels version, wheels info, wheels --help and wheels console show <monorepo version>-dev (for example 4.2.0-dev), the same derivation the framework uses for a checkout, or 0.0.0-dev outside the monorepo. Stamped release versions are unchanged (#3891)
wheels generate (every type except app) and wheels destroy now refuse outside a Wheels project with Wheels.NotAWheelsProject and a non-zero exit, as wheels start does. With no vendor/wheels found, the project root fell back to the current directory, so wheels generate model Foo in any directory wrote app/models/Foo.cfc and a migration there, and wheels destroy could delete a stray app file whose name matched (#3909)
wheels setup --help shows the setup command's own help again, instead of the general command list.
wheels test on an app with no specs yet, such as a fresh wheels new app, now prints a "No specs yet" notice with a hint (wheels generate test model <Name>) and exits 0. Before, it failed with "no test bundles ran for this scope". This applies only to the default scope: an explicit --filter, --directory or path that runs no bundles still fails, and so does any run with failures, errors, a rejected scope or specs that failed to load (#3921).
wheels test --help (and --help for any command that declares its options) now lists every option, with its accepted values and default, followed by examples for running one folder and one spec file. Before, it printed a single line. The list is rendered from the same declaration the parser and the MCP tool schema use (#3962).
The app test runner now clears Lucee's compiled-page pool before every run, not only under wheels coverage. A spec edited just before wheels test is no longer run in its previous version while Lucee's background template check catches up (#3962).
When no server is running, wheels test (and the MCP test tool) now says it needs the project server and to start it with wheels start. It suggests bash tools/test-local.sh only inside the Wheels framework repository, the one place that script exists (#3972).
wheels test accepts a file or folder path as its scope: tests/specs/models, tests/specs/models/BookSpec.cfc, a trailing slash, a leading ./, a path relative to tests/specs, or BookSpec.cfc. Each resolves to the dotted scope the runner takes.
wheels test refuses a scope that names no folder or spec file, or that the runner would reject (including the MCP test tool's directory). It prints one message naming the scope and the accepted forms, before anything runs. Previously it ran the full suite and then reported that no test bundles ran (#3963, #3893).
The AGENTS.md / CLAUDE.md that wheels new writes now show wheels test tests.specs.<area> and --filter=<SpecName>.
An unknown command (wheels status, wheels bogus) now prints Unknown command: <name>. Run 'wheels --help' for the commands., with a "did you mean" for near misses (wheels generat suggests wheels generate), and exits non-zero with Wheels.UnknownCommand. It used to print Lucee's raw "Component [modules.wheels.Module] has no function with name [status]" (#3890)
On CockroachDB, t.string() now creates a VARCHAR(n) column instead of STRING(n). The two are the same type in CockroachDB, but a STRING(n) column reports no length over JDBC, so automatic validations never enforced its limit; VARCHAR(n) reports it. Columns created by earlier versions keep the old behaviour until they are re-declared, for example changeColumn(table = "users", columnName = "name", columnType = "string", limit = 100) with the column's existing limit (#4101)
The migration templates (wheels generate migration, the development migrator, new apps' app/snippets/dbmigrate/) no longer show default='' / default="", which the migrator has rejected on string, text and char columns since 4.1.0. The example header in create-table and change-table, and the changeColumn / addColumn calls in change-column and remove-column, now omit the default.
wheels console: bare exit and quit now end the session like /exit. After a failed expression they exit non-zero (Wheels.ConsoleFailed), as end of input does, so a piped bad expr; exit is still reported as a failure. They used to be evaluated as CFML and fail with "variable [EXIT] doesn't exist", so a piped session that ended in exit exited 1 (#3892)
Controllers include the application's global helper file (app/views/helpers.cfm) when each controller object is set up, and before config() runs, instead of in wheels.Controller's pseudo-constructor. Every controller now gets the global helpers on engines that reuse a class's pseudo-constructor include across instances (RustCFML v0.721.0 and later), including a controller first created during application start-up. Global helpers still come before a controller's own helpers, so a controller helper can override one. A missing global helper file is skipped, and whether it exists is cached like the controller helper files when cacheFileChecking is on.
The framework test suite (/wheels/core/tests) runs in an app made with wheels new. coreTestDataSourceName used to default to a datasource named after the app's folder (public) before config/settings.cfm set dataSourceName, so the run failed with Datasource [public] doesn't exist. It now defaults to the dataSourceName the settings set, so the run uses <datasource>_test. A coreTestDataSourceName that names a datasource that does not exist now gets a 409 explaining how to fix it (set coreTestDataSourceName, create the datasource, or pass ?db=) instead of the engine's error.
A coreTestDataSourceName that differs from dataSourceName only in letter case is treated as the app's primary datasource, so the framework suite still uses <datasource>_test (datasource names are case-insensitive).
The 409 responses of the test runners have lowercase keys (success, error, message, datasource, candidate); on Lucee they were serialized in uppercase.
With csrfStore="cookie", a valid form post was occasionally refused with 403 (about 3% of new CSRF cookies on Lucee 7.0.1 behind Tomcat). That engine reads a cookie value whose only encoded character is %2B back with the + as a space, which made the base64 ciphertext undecryptable. The cookie now carries base64url (-/_, no padding), which has nothing to encode; cookies issued before, including ones already read with a space, still decrypt
On RustCFML, a request that sets the cookie-store CSRF cookie and reads the token back in the same request (for example a page rendering csrfMetaTags()) no longer fails with an HTTP 500. RustCFML reads a cookie assigned as {value, httpOnly, ...} back as that struct rather than its value; the token reader now uses the value
A csrfStore="cookie" app on Adobe CF returned HTTP 500 the first time it generated an authenticity token: the CSRF cookie was set with encodeValue and preserveCase of "" (their defaults), which Adobe rejects for these boolean attributes. They are now left out unless configured.
wheels info and the wheels test preamble now read a single-quoted datasource in config/settings.cfm (set(dataSourceName='name') and env('WHEELS_DATASOURCE', 'name')), which CFML accepts; only double quotes were read (#3952)
delete(callbacks=false) now also skips the callbacks of the dependent records it deletes or removes through hasMany() / hasOne()dependent=, including their afterFind / afterInitialization and transaction callbacks; a hasOne with dependent="deleteAll" or "removeAll" (which loads the record) is covered too. deleteAll(instantiate=true), deleteOne() and updateOne() with callbacks=false no longer run afterFind / afterInitialization on the records they load, like updateAll(instantiate=true). An invalid dependent value on hasMany() or hasOne() now throws Wheels.InvalidArgument when the association is declared, instead of at the first delete
wheels deploy now applies the boot: block (Kamal's rolling boot) instead of only validating it: with boot: present, each role's hosts boot in batches of limit (a count, or a percentage of the role's hosts) with wait seconds between batches, and --dry-run shows where each pause falls. Configs without boot: deploy as before (#3925)
wheels deploy follow-ups: the deploy init Dockerfile no longer hard-codes WHEELS_DATASOURCE=app (the app's own datasource name applies) and links the current guides; the local db/*.sqlite files are no longer copied into the image (the image gets an empty db/, and the generated .dockerignore excludes SQLite files), and deploy init tells a SQLite app that production starts with an empty database and how to run migrations on boot; deploy registry login without registry.username fails with a clear error instead of running docker login -u ''; a missing or empty config/deploy.yml says so (and points at wheels deploy init) instead of a raw file error; unknown proxy.* keys and a non-boolean proxy.ssl are rejected; deploy app details / app live / app maintenance no longer require --release, and the verbs that do ask for --release; deploy config prints proxy, env (secret names only), accessories, ssh and builder too, and shows a registry.password entry only when it names a key in .kamal/secrets; and the deploy guides no longer call setup an alias of deploy (#3914)
wheels deploy now refuses an app or accessory volume whose host path starts with ~ or $ (which a shell used to expand, but which Docker would take literally now that volume arguments are quoted) with a message asking for an absolute path, and wheels deploy init shows a SQLite app the volumes: entry that keeps its database on the host across redeploys. wheels deploy config also prints volumes (#4018)
wheels docs fetch now mounts an already-installed docs bundle into the current app's public/wheels-docs (a second app no longer needs --force), unpacks the bundle with Java instead of a shell unzip (so a CLI home path with a space works, and Windows needs no unzip), refuses archive entries that point outside the docs directory, and honours --offline / WHEELS_OFFLINE=1 by refusing the download while still mounting an installed bundle (#3894, #3899). Thanks @PandaHUN777 (#3915) for the offline-mode fix.
Generated apps no longer prepend blank lines to every response. The per-request and lifecycle event templates (app/events/onrequeststart.cfm, onrequestend.cfm, onsessionstart.cfm and siblings) are $included straight into the response buffer, where a bare cfscript block and a trailing newline leaked whitespace before the action's output — enough to make renderText() of an XML document ill-formed, since the <?xml …?> declaration must be the first byte. The templates are now wrapped in cfsilent with no trailing newline (the wheels new generator templates, the demo app, and the published starter-app example). cfsilent suppresses only output, not execution, so event code — including cfheader — still runs; any output a hook deliberately produces must go through the view or renderText() rather than being written directly. Existing apps: these files are app-owned (app/events/*.cfm), so a framework upgrade does not touch them — if renderText() of XML (or any byte-exact response) shows leading blank lines, wrap each app/events/*.cfm placeholder in <cfsilent>…</cfsilent> with no trailing newline, or delete the empty placeholders you don't use. (#3882)
wheels generate auth --strategy=token and --strategy=jwt: the generated api/Sessions controller answers in JSON when the request names no format (no .json extension or Accept header), instead of an HTTP 500 for a missing view, and its response keys keep their case (token, tokenType, expiresIn, error, revoked, message).
After wheels generate scaffold or generate api-resource, the Next steps now say to run wheels reload when the project's dev server is already running, instead of wheels start. A running server only picks up the new routes after a reload, so following the old hint led to a 404 on the new route and a 500 on its form (#3883). Thanks @kshivam4781 (#3935).
Guide links in framework and CLI messages now reach the 4.2 guides. A link for a 4.2 project or a --to=4.2.0 upgrade check resolved to the 4.1 guides, which have no 4.1 to 4.2 upgrade page, because the newest linkable guides version was still 4.1. The spec that guards that version now counts guide trees still in development, so it can't fall behind again
Cache keys from $hashedKey() now keep argument names and the order of array elements and query rows, while still ignoring struct key order. Cached partials, and other cached output keyed on queries or arrays, are no longer reused for inputs that hold the same values in a different order. The unused normalizeForHash() engine-adapter method is removed.
Job.enqueue(), enqueueIn() and enqueueAt() no longer mutate the data struct the caller passes. The framework's internal keys ($wheelsJobTimeout and, when present, the tenant context) were added to the caller's struct because CFML passes structs by reference; the data is now duplicated before those keys are added. (#3887)
The background-jobs guide (4.1 and 4.2) and the AI jobs reference now describe maxRetries as it works: it counts the retries after the first run, so the default 3 means up to four runs. The log line for a job that runs out of retries now reports how many times it ran.
A job left in processing by a crashed or killed worker is now recovered during normal polling. JobWorker.processNext() (the loop behind wheels jobs work) runs the stale-processing check before claiming, so a job stuck in processing is requeued for retry (counting the attempt) or marked failed when retries are exhausted, instead of staying stuck forever. The reap is scoped to the queues the poll serves and only acts once a job has been idle past a grace margin (timeout + max(60s, timeout)), so a worker never reaps another worker's live job, and the requeue is an atomic conditional UPDATE guarded by the attempt count a claim bumps — two workers polling the same queue cannot double-requeue or double-count the same job. Recovery is at-least-once: workers sharing a queue should configure the same timeout. (#3888)
With jobsAutoCreateTables = false on PostgreSQL or CockroachDB, a job call made inside a database transaction while the job tables are missing now reports Wheels.Job.SchemaMissing, pointing at wheels jobs install. Before, the failed table probe aborted the transaction, and the caller saw "current transaction is aborted" instead.
wheels jobs work no longer cuts every job off at 300 seconds: each job now runs with its own this.timeout, like processQueue(). The new --job-timeout=<seconds> option caps it. (#4517)
The test runner's JSON report keeps its results when a spec errors with an exception that can't be serialized. Each spec's error, failure origin and bundle-level exception are reduced to plain values (type, message, detail, a bounded stack trace and template/line frames) before serializing; before, a JDBC exception on BoxLang sent the serializer into a StackOverflowError and the whole run came back with no results. If serialization still fails, the report keeps the totals and each spec's name and status. (#3905)
On MariaDB 10.2.7 and later, column defaults are read correctly. MariaDB reports them as SQL expressions: a nullable column without a default as the text NULL, and a string default as a quoted literal ('draft'). Wheels took the text NULL as a real default, so validatesPresenceOf was skipped for an unset nullable column on create, and string defaults kept their quotes. The MySQL adapter now normalizes them on MariaDB; a literal string default 'NULL' stays the text NULL, and MySQL is unchanged. (#3927)
Over MCP, generate with type=app (or its alias a) is now refused with Wheels.InvalidArguments instead of scaffolding an application into the MCP server's working directory, even for a client that skips the advertised input schema; wheels generate app <name> and wheels generate --type=app <name> in a terminal keep working. This uses the runtime-owned __lucliMcpCall marker a LuCLI build with wheels-dev/LuCLI#17 adds to every MCP tools/call; Wheels drops that marker before any command parses its arguments, so it is safe with today's LuCLI too (#3980)
MCP tool polish:
wheels notes refuses an annotation marker that isn't a word (a . used to match every character);
wheels seed advertises the models and count options its own "Pass models=…" message refers to, forwards them to the server, and refuses an unknown environment instead of silently running only the main seeds;
the routes tool now has a description, and reload's is no longer cut off mid-sentence (#3893)
A middleware registered by component path (set(middleware=["app.middleware.TokenGate"]) or a route's middleware=[...]) no longer needs an init(): Wheels calls init() only when the component has one. A component with just handle(request, next), which is all wheels.middleware.MiddlewareInterface requires, used to fail every request with "has no function with name [init]". An init() the component has, including an inherited one, still runs, and an object it returns is still what's used; when it returns nothing or a non-object, the component itself is used.
What middleware attaches to the request now reaches the controller. The AuthMiddleware result is available as request.auth, as documented, including the failed result under allowAnonymous=true, and the whole middleware context as request.wheels.middlewareContext. Before, the controller was built from the request params alone, so a controller behind AuthMiddleware could not tell who had authenticated.
AuthMiddleware created without an authenticator now uses the one registered in the DI container (what wheels generate auth and enableSession() register), so the documented new wheels.middleware.AuthMiddleware(strategies="...") form no longer throws Wheels.Auth.NoAuthenticator.
The default AuthMiddleware 401 body now has lowercase error and status keys; on Lucee they were serialized as ERROR and STATUS.
Migration column functions (t.string() and the others, t.column(), t.references()) read null, the option's name before Wheels 3.0, as a deprecated alias of allowNull again. Since 3.0 it was ignored, so an older migration's null=false created a nullable column. allowNull wins when both are passed, and the deprecation is logged once per migration run. wheels upgrade check lists migrations that use null=false or null=true; check null=false columns for NULL rows before tightening them
Migrations on Lucee with CockroachDB: a migration step that runs any statement before a schema change (a check query, a data update, the wheels jobs install migration's catalog checks) no longer fails with "to use multi-statement transactions involving a schema change under weak isolation levels". Lucee leaves a pooled connection at read committed once a transaction on it has ended, and CockroachDB refuses a schema change after another statement at that level. On CockroachDB the migrator now runs each step's transaction at serializable, CockroachDB's own default. Other databases are unchanged.
The migration templates under vendor/wheels/migrator/templates/ no longer show default='', which the migrator has rejected on string, text and char columns since 4.1.0.
Specs that mock (createStub(), createMock(), createEmptyMock(), prepareMock() with $()) work in an app made with wheels new. MockBox writes its generated stubs to public/testbox/system/stubs, which such an app doesn't have, so every mocked method errored with "parent directory … does not exist". WheelsTest now creates the directory when it is missing.
A nested controller (app/controllers/<package>/X.cfc) that declares extends="Controller" now logs a hint to wheels.log to use extends="app.controllers.Controller" — a bare extends name resolves relative to the controller's own package, so the base controller cannot be found. The original component-not-found exception is preserved unchanged (its stack and cause are intact); the hint is added only to the log, which the error page already points to
wheels new creates what the guides and the framework expect:
config/development/settings.cfm, config/testing/settings.cfm, config/production/settings.cfm and config/maintenance/settings.cfm again, as commented stubs (the 3.x template shipped them; the 4.x one didn't). The stubs set nothing, so a new app behaves as before.
.env.example, with the same keys as .env and the secrets (reload and Lucee admin passwords) left blank.
app/middleware/.
app/snippets/dbmigrate/, which the development migrator UI needs to create a migration.
The lucee.json SQLite datasource now uses the #project:path# placeholder LuCLI resolves; the old {project} was never substituted.
The legacy plugins README moved to the root plugins/ folder the framework actually loads plugins from, instead of an app/plugins/ folder it never reads.
New apps no longer ship frozen copies of the generator templates in app/snippets/, which kept an app on old templates after a CLI update.
Template comments link to the 4.1 guides.
wheels new also skips shutdown ports that another project pins in its lucee.json (projects you've started, plus folders next to the new app), even when that project isn't running, and warns when the requested --port is one of them.
wheels new --port=N no longer pins the shutdown port to N+1 when another process is already listening there; it writes the first free port above N into lucee.json and says so. wheels start still moves a pinned shutdown port that is taken later.
The onError fallback in the app's public/Application.cfc no longer reports "Wheels failed to initialize" for an error raised once the application is already running (for example a request timeout after startup); it renders and logs a running-app message instead. It also extends the request timeout (via onErrorRequestTimeout) before logging, so the wheels.log entry the minimal error page points at is written even after a request timeout. Existing apps: this lives in the app-owned public/Application.cfc, so it is not picked up automatically — copy the updated onError, $renderMinimalError and $logStartupFailure functions from the template (cli/lucli/templates/app/public/Application.cfc) into your app's public/Application.cfc, replacing the existing versions. (#3965)
On Oracle, a whole number beyond the signed 64-bit range (more than 9,223,372,036,854,775,807, or less than -9,223,372,036,854,775,808) written to or compared against a NUMBER column is now bound as text, which Oracle converts exactly. It was clamped (Lucee), wrapped (BoxLang) or rounded through a double, so it could be stored or matched as a different number (#4162)
On Oracle, whole-number NUMBER columns with a precision of 11 or more, including NUMBER(38) (Oracle's INTEGER, common for identity ids), are now bound as 64-bit integers. They were bound as 32-bit, so an id above 2,147,483,647 was clamped or wrapped and a find, update or delete by it could act on a different row (#4089)
A lowercase or mixed-case sort direction in an order string is now honoured: order="createdAt desc" (or Desc, or with extra spaces or a tab before it) sorts descending in findAll(), paginated finders, table.column items and the query builder's orderBy(). It used to sort ascending, and a table.column desc item threw Wheels.InvalidOrderClause
wheels packages now checks a package's wheelsVersion range against the app's own vendor/wheels version, not the CLI's (falling back to the CLI version outside a project), and a dev CLI build (0.0.0-dev) treats every range as compatible, as documented. The manifest cache lives under the CLI home (so LUCLI_HOME isolates it), a registry other than the default gets its own registries/<hash>/ cache, and --offline serves expired cached data or says plainly that nothing is cached yet (#3911)
A wheels packages update / add --force killed mid-swap no longer leaves the package only as a hidden vendor/.wheels-pkg-previous-* backup: the next wheels packages command restores it when vendor/<name>/ is missing or incomplete, drops it when the new copy is in place, and says how to restore one it cannot attribute. A cross-volume install now copies into a hidden sibling and renames it in, so vendor/<name>/ is never half-written (#3902)
A polymorphic association without foreignKey / foreignType now finds its columns on the child's table like other associations do: <name>id / <name>type when they exist, otherwise <name>_id / <name>_type, which t.references(polymorphic=true) writes when useUnderscoreReferenceColumns is on (the wheels new default). Before, those apps threw key [<name>id] doesn't exist. When both shapes exist, the names without the underscore win
Job.processQueue() processes jobs again on BoxLang with PostgreSQL and CockroachDB. Its candidate query passed the maxrows option, which BoxLang's PostgreSQL path rejects ("setLargeMaxRows is not yet implemented"), so every call returned an error and ran nothing. It now bounds the batch in the SQL itself, as JobWorker does. (#4487)
processRequest() now restores the request method, transaction mode and the sendEmail / sendFile deliver flags on every exit, including when the action throws and the spec catches it. Previously it restored them only on the success path, so a caught throw leaked that state into later specs in the same run (reported by a downstream app) (#4350)
wheels reload against a RustCFML server no longer fails every other run. On the first request after a reload restart, the framework sets request.cgi before onRequestStart, and RustCFML resolves IsDefined("cgi.<key>") against request.cgi. The X-Wheels-Reload-Password header the CLI sends therefore looked absent, and the reload check never saw it. public/Application.cfc now falls back to the raw request headers (GetHttpRequestData()) when the cgi copy is missing. The password stays header-only, and the reload gate, its rate limit and its constant-time comparison are unchanged. Existing apps: copy the updated header-mapping block from the wheels new template into your own public/Application.cfc (#3913)
On an app served under a subfolder (set(subpath=...) or an IIS application), ?reload=true now redirects back inside the app: public/Application.cfc's $buildRedirectUrl() passes the path through the framework's $reloadRedirectPath(), which puts the subfolder back. Existing apps must apply the same edit to their own public/Application.cfc; the snippet is in the 4.1 → 4.2 upgrade guide (#3948).
The redirect after a reload (redirectAfterReload, on by default in production and maintenance) now collapses a leading run of slashes or backslashes in the request path to a single /, and also ignores spaces and control characters in that leading run, so it always goes to a path on the application's own site; a subfolder install still gets its prefix.
Route model binding now derives the model from the last segment of a namespaced controller, so binding=true on admin.users loads User instead of silently skipping a nonexistent Admin.user model. The development "Route Binding Hint" log line names that model too, and stays quiet when the controller has no model to bind (#3940)
The Linux .rpm now unpacks cleanly with rpm2cpio on rpm 4.19 (Rocky Linux 10). It is rebuilt with rpmbuild, because nfpm wrote an archive-size header that rpm 4.19's rpm2cpio rejects (exit 1), which broke rpm2cpio | cpio installs in user space. The package contents and dependencies are unchanged (#3975)
The Linux packages install where the system Java is newer than 21. The .rpm now requires Java 21 or newer (java-21-headless or java-headless >= 1:21), so dnf install works on Fedora 44, which ships only Java 25, and the .deb accepts OpenJDK 21 or 25, preferring 21. The wheels launcher also finds any Java 21 or newer under /usr/lib/jvm when /usr/bin/java points at an older JDK, and its error now asks for a Java 21 or newer runtime (#4237, #4243)
RustCFML engine commands: wheels info names RustCFML (with its version and URL) while it serves the project instead of always printing Lucee; --port 8931 (space form) now works for wheels engines rustcfml start and is no longer silently dropped by wheels start; wheels start --engine=<unknown> and wheels engines <unknown> / wheels engines rustcfml <unknown> exit non-zero instead of booting Lucee or exiting 0; and the first-run RustCFML download (~57 MB) is announced, with how long it took (#3895)
RustCFML engine: wheels start --engine=rustcfml (and wheels engines rustcfml start) now refuses with Wheels.EngineConflict while the project's Lucee server is running, and wheels start refuses while its RustCFML server is running. Both engines used to run at once on different ports, and the CLI's HTTP commands targeted whichever one port detection picked. wheels stop now stops both: it used to stop RustCFML and leave Lucee running. RustCFML state also honours -Dlucli.home ahead of $LUCLI_HOME, as the rest of the CLI does, so wheels engines rustcfml status no longer misses a running server under a launcher that passes the property (#3913)
Corrected the session-cookie (#3830) upgrade note's version range: the this.sessionCookie block has been in the app template since 4.0.4, so apps created with 4.0.4-4.1.1 (not only 4.0.6-4.1.1) need the secure update, and apps created before 4.0.4 have no block at all. Added a dedicated Upgrading from 4.0 to 4.1 guide page listing every app-owned public/Application.cfc edit with its version and fail-safe status. (#3967, #3966, #3968)
On SQLite, t.bigInteger() now declares BIGINT instead of INTEGER, so the column is bound as a 64-bit integer. Values above 2,147,483,647 used to fail on Adobe ColdFusion and were silently changed on Lucee (clamped to 2,147,483,647) and BoxLang (wrapped). Existing columns keep INTEGER until re-declared with changeColumn(columnType="biginteger"); a biginteger primary key stays INTEGER PRIMARY KEY, SQLite's rowid (#4089)
On Oracle, a calculated property declared with dataType="bigint" now maps to cf_sql_bigint instead of failing with "The column type bigint is not mapped to a CFML SQL type" (#4089)
On SQLite, INTEGER columns, including INTEGER PRIMARY KEY ids, are now bound as 64-bit integers, matching how SQLite stores them. A value or id above 2,147,483,647 used to fail on Adobe ColdFusion and was silently changed on Lucee (clamped to 2,147,483,647) and BoxLang (wrapped), so a find, update or delete by such an id could act on a different row. Existing INTEGER columns are protected without a migration (#4142)
On SQLite, updateAll() now stores a date as ISO-8601 text, as save() does, instead of CFML's {ts '...'} string, which a DATETIME column then failed to read ("Error parsing time stamp"). changeColumn() to a date type converts such values left by earlier versions ({ts}, {d}, {t} literals and bare date-only or time-only text) to the form save() writes; the 4.1 to 4.2 upgrade guide has a query to find and repair them by hand (#4147)
SSE channel resume now honours the bundled WheelsSSE client's lastEventId url parameter, not just the Last-Event-ID header, so a reconnect replays the events it missed out of the box — you no longer have to forward params.lastEventId from the action (the header still wins when both are present). The channel events filter now trims whitespace, so events="a, b" matches b (#4375)
wheels start stops before starting anything when the port in lucee.json is taken, names the Wheels server that holds it (with cd <its folder> && wheels stop), and suggests a free --port. It used to warn and then fail with LuCLI's Use: lucli server stop <name>.
wheels new without --port no longer gives every app port 8080: it takes the first port from 8080 that is free and not pinned in another project's lucee.json.
The wheels-starter-app package is published to ForgeBox under the release version. Its box.json carried a fixed 1.0.0, which the build never replaced, so every release overwrote ForgeBox's 1.0.0 entry and no 4.x starter app was listed under its own version. The release build's package check now fails on a version mismatch instead of printing a warning. (#3908)
The starter app's README Quick Start works again: it creates the schema from the migrator page (/wheels/migrator) instead of wheels migrate latest, which needs a wheels start server, and restarts the server afterwards so the seeded settings load.
The starter app's migrations create their schema again: nine default='' options on string and text columns, rejected since 4.1.0, are gone, and the migration header examples and the create-table snippet no longer teach the pattern.
The starter app's tests run again: its tests/runner.cfm, an old copy of the framework's core-test runner that set the datasource from the folder name ("Datasource [public] doesn't exist"), is replaced by the app template's runner, which runs the specs on the starterApp_test datasource.
wheels stop now stops the project's running server even when it runs under an older registration name (after a lucee.jsonname change), instead of stopping only the current name and leaving the old-name server running, and it deletes the live server's start token rather than a stale registration's (#3994)
Apps running under a subfolder (/app1 as an IIS virtual directory or Tomcat context pointing at public/) no longer lose the subfolder in five places (#3948):
/app1/index.cfm with an empty PATH_INFO now routes to the root instead of an App1 controller.
?reload=true redirects back under /app1.
tests/runner.cfm falls back to the /wheels mapping when /app1/wheels/... does not exist.
The debug bar's environment-switch links keep index.cfm in Partial URL rewriting.
The debug bar's offline-docs links point under the subfolder.
TenantMigrator.migrateAll() lists a tenant whose migration step failed under failed (with error, version, direction and the full output) instead of success. The migrator reports a failed step in its output rather than throwing, so the tenant used to be counted as migrated. The migrator's output text is unchanged; Migrator.$lastStepFailure() exposes the failed step as data.
The wheels.Test (RocketUnit) deprecation message now links the v4-1-0 testing guides instead of the stale v4-0-0 path. (#3968)
Test-runner URLs beyond /wheels/core/tests and /wheels/app/tests now bind the isolated test application too: the testbox and tests_testbox runner paths are matched (case-insensitively, anchored at the start of the path), so a run reached through a convention path such as /wheels/testbox no longer executes specs against the live application. On an app configured for isolation, a runner reached through a route that does not bind the isolated scope (a custom route) is now refused with a 409 naming the canonical runner path; apps without the isolation include keep their existing live-scope behaviour (#4379)
TestClient keeps and returns cookies correctly on Adobe CF. Adobe hands repeated Set-Cookie headers over as a struct, and the client walked its keys instead of its values, so no cookie was kept and every request started a new session. And cookies went back through cfhttpparam type="cookie", which URL-encodes again, so a value the server had already encoded (such as an encrypted token) arrived double-encoded. The client now reads every Set-Cookie shape and sends the jar back as one Cookie header, values exactly as the s