From 786b396acb9d53b0ffef19fbb27d7c39fd964045 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sun, 11 Oct 2026 11:31:40 -0700 Subject: [PATCH 1/3] chore(release): promote changelog fragments into 4.2.0 Runs tools/changelog-promote.sh 4.2.0 2026-10-11: assembles the 272 changelog.d fragments into a new 4.2.0 section and empties changelog.d. The date is the prep date and moves to the tag date at the cut. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- CHANGELOG.md | 358 ++++++++++++++++++ .../3896-finder-boolean-values.fixed.md | 1 - changelog.d/3897-mysql-boolean-bit.changed.md | 1 - changelog.d/3897-oracle-boolean.changed.md | 1 - ...stcfml-stringindex-override.performance.md | 1 - .../3903-where-literal-scan.performance.md | 1 - ...here-masker-linear-rustcfml.performance.md | 1 - changelog.d/3906-in-list-limits.fixed.md | 1 - .../3907-where-in-large-integer-list.fixed.md | 1 - .../3958-transaction-savepoint.added.md | 1 - .../4055-empty-string-where-values.changed.md | 1 - ...059-transaction-isolation-setting.added.md | 1 - changelog.d/4084-oracle-inline-dates.fixed.md | 1 - .../4087-nested-small-integer-keys.fixed.md | 1 - changelog.d/4093-sqlite-date-columns.fixed.md | 1 - ...igrator-unsupported-column-type.changed.md | 1 - .../4099-channel-poll-ordering.fixed.md | 1 - .../4103-sqlserver-long-in-lists.added.md | 1 - .../4106-seq-probe-schema-scope.fixed.md | 1 - changelog.d/4134-migration-lock.changed.md | 1 - ...149-controller-construction.performance.md | 1 - .../4150-memoise-inflection.performance.md | 1 - ...4151-linkto-urlfor-overhead.performance.md | 1 - changelog.d/4168-cli-help-user-text.fixed.md | 1 - .../4172-wide-decimal-exact-bind.fixed.md | 1 - ...74-promote-globals-metadata.performance.md | 1 - ...185-pluralize-boxlang-rule-groups.fixed.md | 1 - ...7-singularize-prefixed-analyses.changed.md | 1 - .../4189-singularize-databases.changed.md | 1 - changelog.d/4194-snapshot-routes.added.md | 1 - changelog.d/4195-testclient-baseurl.fixed.md | 1 - .../4197-advisory-lock-exclusion.fixed.md | 1 - .../4206-packages-install-alias-help.fixed.md | 1 - changelog.d/4209-migrate-unlock.added.md | 1 - .../4219-testclient-get-timeout.added.md | 1 - ...qlserver-standalone-advisory-lock.fixed.md | 1 - ...advisory-lock-tenant-datasource.changed.md | 1 - .../4232-cli-bounded-local-waits.fixed.md | 1 - ...4318-sqlserver-long-date-in-lists.added.md | 1 - ...326-sqlserver-datetime-equality.changed.md | 1 - .../4327-sqlserver-time-conditions.fixed.md | 1 - .../4370-soft-delete-dependents.changed.md | 1 - .../4371-soft-delete-false-purges.changed.md | 1 - .../4372-reload-missing-row.changed.md | 1 - .../4372-soft-deleted-instance.fixed.md | 4 - .../4374-order-qualified-quoting.fixed.md | 1 - .../4376-upgrade-check-renderpage.fixed.md | 1 - ...2-empty-mixins-early-return.performance.md | 1 - .../4405-migrate-diff-accuracy.fixed.md | 3 - .../4407-db-status-applied-at.fixed.md | 1 - .../4408-test-core-runner-error.fixed.md | 1 - changelog.d/4409-cli-exit-codes.fixed.md | 1 - changelog.d/4410-console-routes.fixed.md | 1 - .../4411-start-no-side-effects.fixed.md | 2 - .../4413-setup-h2-datasources.fixed.md | 1 - .../4414-controller-no-actions.fixed.md | 1 - .../4415-deploy-builder-options.fixed.md | 2 - changelog.d/4417-deploy-arg-handling.fixed.md | 5 - changelog.d/4418-cli-help-text.fixed.md | 1 - .../4429-request-cache-cross-model.fixed.md | 1 - .../4433-cookie-flash-same-request.fixed.md | 1 - ...4434-diff-subfolders-seed-snippet.fixed.md | 2 - .../4448-injector-cfthread-resolving.fixed.md | 1 - .../4470-public-subfolder-mappings.fixed.md | 1 - .../4475-processqueue-through-worker.fixed.md | 1 - changelog.d/4476-job-claim-fencing.fixed.md | 1 - changelog.d/4477-job-heartbeat.added.md | 2 - changelog.d/4478-job-unique-key.added.md | 1 - .../4479-exclusive-jobs-lease-lock.added.md | 1 - changelog.d/4480-job-hosts-cap-drain.added.md | 5 - changelog.d/4480-jobs-drain-cli.added.md | 1 - changelog.d/4481-job-schedules.added.md | 11 - changelog.d/4482-jobs-tick-route.added.md | 1 - changelog.d/4483-job-lifecycle-hooks.added.md | 1 - changelog.d/4484-jobs-install.added.md | 1 - .../4489-unique-key-sqlserver-compat.fixed.md | 1 - changelog.d/4502-heartbeat-grace.fixed.md | 1 - ...4525-jobclock-rustcfml-local-time.fixed.md | 1 - ...dobe-nested-transaction-isolation.fixed.md | 1 - .../adobe-raw-transaction-detection.fixed.md | 1 - changelog.d/after-commit-callbacks.added.md | 1 - changelog.d/after-rollback-timing.changed.md | 1 - .../agent-readable-dev-errors.added.md | 1 - changelog.d/agents-md-mcp-setup.fixed.md | 1 - .../api-resource-controller-guards.fixed.md | 6 - ...ource-route-outside-marked-blocks.fixed.md | 1 - changelog.d/app-data-cache.added.md | 1 - .../app-test-default-test-db.changed.md | 1 - changelog.d/app-test-run-timeout.changed.md | 1 - .../association-jointype-left.fixed.md | 1 - .../base-template-consumer-ai-docs.fixed.md | 1 - changelog.d/bigint-column-types.fixed.md | 1 - .../browser-classpath-separator.fixed.md | 1 - changelog.d/browser-install-alias.changed.md | 1 - ...owser-launch-failure-not-a-pass.changed.md | 1 - .../browser-setup-launch-probe.fixed.md | 1 - .../bulk-batch-parameter-limit.fixed.md | 1 - .../bulk-insert-transaction.changed.md | 1 - .../callbacks-false-commit-callbacks.fixed.md | 1 - changelog.d/char-column-type.fixed.md | 1 - .../claimtimeout-alter-backoff.fixed.md | 1 - ...clear-change-information-property.fixed.md | 1 - .../cli-apply-report-new-help.fixed.md | 2 - changelog.d/cli-dev-checkout-version.fixed.md | 1 - .../cli-generate-outside-project.fixed.md | 1 - changelog.d/cli-setup-help.fixed.md | 1 - .../cli-test-default-scope-empty.fixed.md | 1 - ...cli-test-filter-exact-containment.fixed.md | 1 - .../cli-test-help-and-page-pool.fixed.md | 2 - changelog.d/cli-test-no-server-hint.fixed.md | 1 - changelog.d/cli-test-path-scope.fixed.md | 3 - changelog.d/cli-unknown-command.fixed.md | 1 - changelog.d/clock-seam-travelto.added.md | 1 - .../cockroachdb-string-length.fixed.md | 1 - .../codegen-migration-empty-defaults.fixed.md | 1 - changelog.d/console-bare-exit.fixed.md | 1 - .../consumer-claude-md-split.changed.md | 1 - ...roller-global-helpers-init-time.changed.md | 1 - ...oller-global-helpers-per-instance.fixed.md | 1 - .../core-test-datasource-default.changed.md | 1 - .../core-test-datasource-default.fixed.md | 3 - ...ore-test-runner-test-datasource.changed.md | 1 - changelog.d/csrf-cookie-base64url.fixed.md | 1 - .../csrf-cookie-same-request-read.fixed.md | 1 - changelog.d/csrf-cookie-store-adobe.fixed.md | 1 - .../csrf-header-any-request.changed.md | 2 - changelog.d/datasource-single-quotes.fixed.md | 1 - changelog.d/dependent-callbacks.fixed.md | 1 - .../deploy-accessory-port-bind.changed.md | 1 - changelog.d/deploy-app-volumes.added.md | 1 - changelog.d/deploy-boot-limit-wait.fixed.md | 1 - changelog.d/deploy-followups-3914.fixed.md | 1 - changelog.d/deploy-migrate-step.added.md | 2 - .../deploy-volumes-absolute-host.fixed.md | 1 - changelog.d/devtools-loopback-peer.fixed.md | 1 - changelog.d/docs-fetch-mount-offline.fixed.md | 1 - ...mpty-default-non-string-columns.changed.md | 1 - changelog.d/escape-for-like.added.md | 1 - .../event-template-whitespace.fixed.md | 1 - .../f23-testclient-from-address.added.md | 1 - .../findall-returnas-structs-array.changed.md | 2 - changelog.d/forget-cached-queries.added.md | 1 - .../framework-install-command.added.md | 1 - changelog.d/generate-auth-api-json.fixed.md | 1 - .../generate-auth-token-index-docs.changed.md | 1 - ...enerate-migration-property-hint.changed.md | 1 - .../generate-next-steps-reload.fixed.md | 1 - changelog.d/guides-link-latest-4-2.fixed.md | 1 - .../guides-links-follow-version.changed.md | 1 - changelog.d/hashed-key-order.fixed.md | 1 - .../hasmanycheckbox-join-models.fixed.md | 3 - changelog.d/is-safe-redirect-url.added.md | 1 - changelog.d/job-claim-timeout.changed.md | 1 - changelog.d/job-cron-steps.changed.md | 1 - .../job-defer-cross-datasource.changed.md | 1 - .../job-enqueue-duplicate-data.fixed.md | 1 - .../job-enqueue-failure-throws.changed.md | 1 - changelog.d/job-maxretries-docs.fixed.md | 1 - .../job-stale-processing-reaper.fixed.md | 1 - changelog.d/job-survives-rollback.added.md | 1 - changelog.d/job-utc-clock.changed.md | 1 - changelog.d/jobs-enqueue-cli.added.md | 1 - ...obs-schema-missing-in-transaction.fixed.md | 1 - changelog.d/jobs-stop-when-empty.added.md | 1 - changelog.d/jobs-tick-settings.added.md | 1 - changelog.d/jobs-work-timeout.fixed.md | 1 - .../json-reporter-plain-errors.fixed.md | 1 - .../localdisk-key-validation.security.md | 1 - .../localdisk-resolve-symlinks.added.md | 1 - .../lucli-0-6-2-2-localhost.changed.md | 1 - changelog.d/lucli-0-6-2-3.changed.md | 1 - changelog.d/mariadb-column-defaults.fixed.md | 1 - changelog.d/mcp-hide-app-creation.changed.md | 1 - changelog.d/mcp-refuse-generate-app.fixed.md | 1 - changelog.d/mcp-tool-polish.fixed.md | 4 - changelog.d/middleware-init-optional.fixed.md | 1 - ...ddleware-results-reach-controller.fixed.md | 3 - changelog.d/migration-null-alias.fixed.md | 1 - .../migrator-crdb-serializable-step.fixed.md | 1 - ...igrator-vendor-templates-defaults.fixed.md | 1 - changelog.d/mock-stub-directory.fixed.md | 1 - ...l-generator-required-by-default.changed.md | 1 - .../mysql-inline-string-literals.fixed.md | 1 - .../mysql-signed-biginteger.changed.md | 1 - changelog.d/nested-child-scope.fixed.md | 1 - .../nested-controller-extends-hint.fixed.md | 1 - changelog.d/new-app-commits-vendor.changed.md | 1 - .../new-app-environment-settings.fixed.md | 9 - .../new-app-routes-root-first.changed.md | 1 - changelog.d/new-avoid-pinned-ports.fixed.md | 1 - changelog.d/new-shutdown-port-probe.fixed.md | 1 - .../new-writes-agent-configs.changed.md | 1 - changelog.d/onerror-timeout-message.fixed.md | 1 - changelog.d/oracle-beyond-64bit.fixed.md | 1 - .../oracle-number-precision.changed.md | 1 - changelog.d/oracle-number38-bigint.fixed.md | 1 - changelog.d/order-direction-case.fixed.md | 1 - .../packages-runtime-cache-offline.fixed.md | 1 - changelog.d/packages-swap-recovery.fixed.md | 1 - .../pathguard-exact-containment.fixed.md | 1 - changelog.d/polymorphic-foreign-type.added.md | 1 - .../polymorphic-schema-columns.fixed.md | 1 - ...rependurl-forwarded-proto-trust.changed.md | 1 - .../processqueue-boxlang-postgres.fixed.md | 1 - .../processrequest-state-restore.fixed.md | 1 - changelog.d/prod-error-page-status.changed.md | 1 - ...ect-test-runner-test-datasource.changed.md | 1 - changelog.d/public-render-notfound.added.md | 1 - changelog.d/query-assertions.added.md | 1 - .../query-builder-date-values.changed.md | 1 - ...load-header-rustcfml-cold-request.fixed.md | 1 - .../reload-redirect-keeps-subfolder.fixed.md | 1 - .../reload-redirect-leading-slashes.fixed.md | 1 - ...te-binding-namespaced-controllers.fixed.md | 1 - changelog.d/route-index.performance.md | 1 - changelog.d/rpm-rpmbuild-rewrap.fixed.md | 1 - changelog.d/rustcfml-engine-cli.fixed.md | 1 - .../rustcfml-engine-exclusivity-home.fixed.md | 1 - changelog.d/saved-changes.added.md | 1 - changelog.d/schedule-runner-wiring.added.md | 1 - changelog.d/scoped-key-lookups.fixed.md | 1 - ...session-cookie-upgrade-note-range.fixed.md | 1 - changelog.d/sqlite-biginteger-ddl.fixed.md | 2 - .../sqlite-integer-bind-64bit.fixed.md | 1 - changelog.d/sqlite-updateall-date.fixed.md | 1 - changelog.d/sse-resume-lasteventid.fixed.md | 1 - changelog.d/start-port-in-use-hint.fixed.md | 2 - ...tarter-app-application-cfc-sync.changed.md | 1 - .../starter-app-forgebox-version.fixed.md | 1 - .../starter-app-readme-migrate.fixed.md | 1 - .../starter-app-string-defaults.fixed.md | 1 - changelog.d/starter-app-test-runner.fixed.md | 1 - changelog.d/starter-app-wheels-cli.changed.md | 1 - changelog.d/stop-live-registration.fixed.md | 1 - changelog.d/strict-arguments.added.md | 1 - changelog.d/subfolder-install-paths.fixed.md | 6 - .../template-reads-wheels-env.security.md | 1 - ...ant-debug-flags-nonoverridable.security.md | 1 - .../tenant-migrator-failed-step.fixed.md | 1 - changelog.d/test-cfc-guides-version.fixed.md | 1 - .../test-run-live-scope-warning.changed.md | 1 - changelog.d/test-url-isolation.fixed.md | 1 - .../testclient-adobe-set-cookie.fixed.md | 1 - changelog.d/testclient-csrf.added.md | 1 - changelog.d/testclient-test-context.fixed.md | 4 - .../testcontext-path-trigger-anchor.fixed.md | 1 - .../testcontext-selection-gate.fixed.md | 1 - .../transaction-abort-rollback.fixed.md | 1 - changelog.d/tx-advisory-locks.added.md | 1 - .../uniqueidentifier-column-types.fixed.md | 1 - .../upgrade-apply-backup-followups.fixed.md | 1 - ...ade-apply-backup-outside-vendor.changed.md | 1 - .../upgrade-apply-box-json-pin.fixed.md | 1 - changelog.d/upgrade-check-3x.fixed.md | 1 - changelog.d/upgrade-check-4x1-to-4x2.added.md | 1 - .../upgrade-check-4x2-advisories.added.md | 1 - .../upgrade-check-absent-hint.changed.md | 1 - ...grade-check-application-cfc-fixes.added.md | 1 - .../upgrade-check-newer-target.fixed.md | 1 - ...upgrade-check-plugin-dependencies.fixed.md | 1 - ...pgrade-check-wirebox-scan-targets.fixed.md | 1 - .../urlrewrite-backslash-path.fixed.md | 1 - .../urlrewrite-dead-format-rule.removed.md | 1 - ...ation-condition-fail-closed-short.fixed.md | 1 - .../validation-this-path-fail-closed.fixed.md | 1 - .../verifies-failure-status.changed.md | 1 - changelog.d/wheels-lookup.added.md | 1 - .../where-function-left-bound.fixed.md | 1 - .../where-function-value-include.fixed.md | 1 - changelog.d/where-literal-boundaries.fixed.md | 1 - changelog.d/where-literal-predicates.fixed.md | 1 - changelog.d/where-odbc-date-literal.fixed.md | 1 - .../zero-row-bulk-op-rollback.fixed.md | 1 - 273 files changed, 358 insertions(+), 338 deletions(-) delete mode 100644 changelog.d/3896-finder-boolean-values.fixed.md delete mode 100644 changelog.d/3897-mysql-boolean-bit.changed.md delete mode 100644 changelog.d/3897-oracle-boolean.changed.md delete mode 100644 changelog.d/3903-rustcfml-stringindex-override.performance.md delete mode 100644 changelog.d/3903-where-literal-scan.performance.md delete mode 100644 changelog.d/3903-where-masker-linear-rustcfml.performance.md delete mode 100644 changelog.d/3906-in-list-limits.fixed.md delete mode 100644 changelog.d/3907-where-in-large-integer-list.fixed.md delete mode 100644 changelog.d/3958-transaction-savepoint.added.md delete mode 100644 changelog.d/4055-empty-string-where-values.changed.md delete mode 100644 changelog.d/4059-transaction-isolation-setting.added.md delete mode 100644 changelog.d/4084-oracle-inline-dates.fixed.md delete mode 100644 changelog.d/4087-nested-small-integer-keys.fixed.md delete mode 100644 changelog.d/4093-sqlite-date-columns.fixed.md delete mode 100644 changelog.d/4095-migrator-unsupported-column-type.changed.md delete mode 100644 changelog.d/4099-channel-poll-ordering.fixed.md delete mode 100644 changelog.d/4103-sqlserver-long-in-lists.added.md delete mode 100644 changelog.d/4106-seq-probe-schema-scope.fixed.md delete mode 100644 changelog.d/4134-migration-lock.changed.md delete mode 100644 changelog.d/4149-controller-construction.performance.md delete mode 100644 changelog.d/4150-memoise-inflection.performance.md delete mode 100644 changelog.d/4151-linkto-urlfor-overhead.performance.md delete mode 100644 changelog.d/4168-cli-help-user-text.fixed.md delete mode 100644 changelog.d/4172-wide-decimal-exact-bind.fixed.md delete mode 100644 changelog.d/4174-promote-globals-metadata.performance.md delete mode 100644 changelog.d/4185-pluralize-boxlang-rule-groups.fixed.md delete mode 100644 changelog.d/4187-singularize-prefixed-analyses.changed.md delete mode 100644 changelog.d/4189-singularize-databases.changed.md delete mode 100644 changelog.d/4194-snapshot-routes.added.md delete mode 100644 changelog.d/4195-testclient-baseurl.fixed.md delete mode 100644 changelog.d/4197-advisory-lock-exclusion.fixed.md delete mode 100644 changelog.d/4206-packages-install-alias-help.fixed.md delete mode 100644 changelog.d/4209-migrate-unlock.added.md delete mode 100644 changelog.d/4219-testclient-get-timeout.added.md delete mode 100644 changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md delete mode 100644 changelog.d/4223-advisory-lock-tenant-datasource.changed.md delete mode 100644 changelog.d/4232-cli-bounded-local-waits.fixed.md delete mode 100644 changelog.d/4318-sqlserver-long-date-in-lists.added.md delete mode 100644 changelog.d/4326-sqlserver-datetime-equality.changed.md delete mode 100644 changelog.d/4327-sqlserver-time-conditions.fixed.md delete mode 100644 changelog.d/4370-soft-delete-dependents.changed.md delete mode 100644 changelog.d/4371-soft-delete-false-purges.changed.md delete mode 100644 changelog.d/4372-reload-missing-row.changed.md delete mode 100644 changelog.d/4372-soft-deleted-instance.fixed.md delete mode 100644 changelog.d/4374-order-qualified-quoting.fixed.md delete mode 100644 changelog.d/4376-upgrade-check-renderpage.fixed.md delete mode 100644 changelog.d/4382-empty-mixins-early-return.performance.md delete mode 100644 changelog.d/4405-migrate-diff-accuracy.fixed.md delete mode 100644 changelog.d/4407-db-status-applied-at.fixed.md delete mode 100644 changelog.d/4408-test-core-runner-error.fixed.md delete mode 100644 changelog.d/4409-cli-exit-codes.fixed.md delete mode 100644 changelog.d/4410-console-routes.fixed.md delete mode 100644 changelog.d/4411-start-no-side-effects.fixed.md delete mode 100644 changelog.d/4413-setup-h2-datasources.fixed.md delete mode 100644 changelog.d/4414-controller-no-actions.fixed.md delete mode 100644 changelog.d/4415-deploy-builder-options.fixed.md delete mode 100644 changelog.d/4417-deploy-arg-handling.fixed.md delete mode 100644 changelog.d/4418-cli-help-text.fixed.md delete mode 100644 changelog.d/4429-request-cache-cross-model.fixed.md delete mode 100644 changelog.d/4433-cookie-flash-same-request.fixed.md delete mode 100644 changelog.d/4434-diff-subfolders-seed-snippet.fixed.md delete mode 100644 changelog.d/4448-injector-cfthread-resolving.fixed.md delete mode 100644 changelog.d/4470-public-subfolder-mappings.fixed.md delete mode 100644 changelog.d/4475-processqueue-through-worker.fixed.md delete mode 100644 changelog.d/4476-job-claim-fencing.fixed.md delete mode 100644 changelog.d/4477-job-heartbeat.added.md delete mode 100644 changelog.d/4478-job-unique-key.added.md delete mode 100644 changelog.d/4479-exclusive-jobs-lease-lock.added.md delete mode 100644 changelog.d/4480-job-hosts-cap-drain.added.md delete mode 100644 changelog.d/4480-jobs-drain-cli.added.md delete mode 100644 changelog.d/4481-job-schedules.added.md delete mode 100644 changelog.d/4482-jobs-tick-route.added.md delete mode 100644 changelog.d/4483-job-lifecycle-hooks.added.md delete mode 100644 changelog.d/4484-jobs-install.added.md delete mode 100644 changelog.d/4489-unique-key-sqlserver-compat.fixed.md delete mode 100644 changelog.d/4502-heartbeat-grace.fixed.md delete mode 100644 changelog.d/4525-jobclock-rustcfml-local-time.fixed.md delete mode 100644 changelog.d/adobe-nested-transaction-isolation.fixed.md delete mode 100644 changelog.d/adobe-raw-transaction-detection.fixed.md delete mode 100644 changelog.d/after-commit-callbacks.added.md delete mode 100644 changelog.d/after-rollback-timing.changed.md delete mode 100644 changelog.d/agent-readable-dev-errors.added.md delete mode 100644 changelog.d/agents-md-mcp-setup.fixed.md delete mode 100644 changelog.d/api-resource-controller-guards.fixed.md delete mode 100644 changelog.d/api-resource-route-outside-marked-blocks.fixed.md delete mode 100644 changelog.d/app-data-cache.added.md delete mode 100644 changelog.d/app-test-default-test-db.changed.md delete mode 100644 changelog.d/app-test-run-timeout.changed.md delete mode 100644 changelog.d/association-jointype-left.fixed.md delete mode 100644 changelog.d/base-template-consumer-ai-docs.fixed.md delete mode 100644 changelog.d/bigint-column-types.fixed.md delete mode 100644 changelog.d/browser-classpath-separator.fixed.md delete mode 100644 changelog.d/browser-install-alias.changed.md delete mode 100644 changelog.d/browser-launch-failure-not-a-pass.changed.md delete mode 100644 changelog.d/browser-setup-launch-probe.fixed.md delete mode 100644 changelog.d/bulk-batch-parameter-limit.fixed.md delete mode 100644 changelog.d/bulk-insert-transaction.changed.md delete mode 100644 changelog.d/callbacks-false-commit-callbacks.fixed.md delete mode 100644 changelog.d/char-column-type.fixed.md delete mode 100644 changelog.d/claimtimeout-alter-backoff.fixed.md delete mode 100644 changelog.d/clear-change-information-property.fixed.md delete mode 100644 changelog.d/cli-apply-report-new-help.fixed.md delete mode 100644 changelog.d/cli-dev-checkout-version.fixed.md delete mode 100644 changelog.d/cli-generate-outside-project.fixed.md delete mode 100644 changelog.d/cli-setup-help.fixed.md delete mode 100644 changelog.d/cli-test-default-scope-empty.fixed.md delete mode 100644 changelog.d/cli-test-filter-exact-containment.fixed.md delete mode 100644 changelog.d/cli-test-help-and-page-pool.fixed.md delete mode 100644 changelog.d/cli-test-no-server-hint.fixed.md delete mode 100644 changelog.d/cli-test-path-scope.fixed.md delete mode 100644 changelog.d/cli-unknown-command.fixed.md delete mode 100644 changelog.d/clock-seam-travelto.added.md delete mode 100644 changelog.d/cockroachdb-string-length.fixed.md delete mode 100644 changelog.d/codegen-migration-empty-defaults.fixed.md delete mode 100644 changelog.d/console-bare-exit.fixed.md delete mode 100644 changelog.d/consumer-claude-md-split.changed.md delete mode 100644 changelog.d/controller-global-helpers-init-time.changed.md delete mode 100644 changelog.d/controller-global-helpers-per-instance.fixed.md delete mode 100644 changelog.d/core-test-datasource-default.changed.md delete mode 100644 changelog.d/core-test-datasource-default.fixed.md delete mode 100644 changelog.d/core-test-runner-test-datasource.changed.md delete mode 100644 changelog.d/csrf-cookie-base64url.fixed.md delete mode 100644 changelog.d/csrf-cookie-same-request-read.fixed.md delete mode 100644 changelog.d/csrf-cookie-store-adobe.fixed.md delete mode 100644 changelog.d/csrf-header-any-request.changed.md delete mode 100644 changelog.d/datasource-single-quotes.fixed.md delete mode 100644 changelog.d/dependent-callbacks.fixed.md delete mode 100644 changelog.d/deploy-accessory-port-bind.changed.md delete mode 100644 changelog.d/deploy-app-volumes.added.md delete mode 100644 changelog.d/deploy-boot-limit-wait.fixed.md delete mode 100644 changelog.d/deploy-followups-3914.fixed.md delete mode 100644 changelog.d/deploy-migrate-step.added.md delete mode 100644 changelog.d/deploy-volumes-absolute-host.fixed.md delete mode 100644 changelog.d/devtools-loopback-peer.fixed.md delete mode 100644 changelog.d/docs-fetch-mount-offline.fixed.md delete mode 100644 changelog.d/empty-default-non-string-columns.changed.md delete mode 100644 changelog.d/escape-for-like.added.md delete mode 100644 changelog.d/event-template-whitespace.fixed.md delete mode 100644 changelog.d/f23-testclient-from-address.added.md delete mode 100644 changelog.d/findall-returnas-structs-array.changed.md delete mode 100644 changelog.d/forget-cached-queries.added.md delete mode 100644 changelog.d/framework-install-command.added.md delete mode 100644 changelog.d/generate-auth-api-json.fixed.md delete mode 100644 changelog.d/generate-auth-token-index-docs.changed.md delete mode 100644 changelog.d/generate-migration-property-hint.changed.md delete mode 100644 changelog.d/generate-next-steps-reload.fixed.md delete mode 100644 changelog.d/guides-link-latest-4-2.fixed.md delete mode 100644 changelog.d/guides-links-follow-version.changed.md delete mode 100644 changelog.d/hashed-key-order.fixed.md delete mode 100644 changelog.d/hasmanycheckbox-join-models.fixed.md delete mode 100644 changelog.d/is-safe-redirect-url.added.md delete mode 100644 changelog.d/job-claim-timeout.changed.md delete mode 100644 changelog.d/job-cron-steps.changed.md delete mode 100644 changelog.d/job-defer-cross-datasource.changed.md delete mode 100644 changelog.d/job-enqueue-duplicate-data.fixed.md delete mode 100644 changelog.d/job-enqueue-failure-throws.changed.md delete mode 100644 changelog.d/job-maxretries-docs.fixed.md delete mode 100644 changelog.d/job-stale-processing-reaper.fixed.md delete mode 100644 changelog.d/job-survives-rollback.added.md delete mode 100644 changelog.d/job-utc-clock.changed.md delete mode 100644 changelog.d/jobs-enqueue-cli.added.md delete mode 100644 changelog.d/jobs-schema-missing-in-transaction.fixed.md delete mode 100644 changelog.d/jobs-stop-when-empty.added.md delete mode 100644 changelog.d/jobs-tick-settings.added.md delete mode 100644 changelog.d/jobs-work-timeout.fixed.md delete mode 100644 changelog.d/json-reporter-plain-errors.fixed.md delete mode 100644 changelog.d/localdisk-key-validation.security.md delete mode 100644 changelog.d/localdisk-resolve-symlinks.added.md delete mode 100644 changelog.d/lucli-0-6-2-2-localhost.changed.md delete mode 100644 changelog.d/lucli-0-6-2-3.changed.md delete mode 100644 changelog.d/mariadb-column-defaults.fixed.md delete mode 100644 changelog.d/mcp-hide-app-creation.changed.md delete mode 100644 changelog.d/mcp-refuse-generate-app.fixed.md delete mode 100644 changelog.d/mcp-tool-polish.fixed.md delete mode 100644 changelog.d/middleware-init-optional.fixed.md delete mode 100644 changelog.d/middleware-results-reach-controller.fixed.md delete mode 100644 changelog.d/migration-null-alias.fixed.md delete mode 100644 changelog.d/migrator-crdb-serializable-step.fixed.md delete mode 100644 changelog.d/migrator-vendor-templates-defaults.fixed.md delete mode 100644 changelog.d/mock-stub-directory.fixed.md delete mode 100644 changelog.d/model-generator-required-by-default.changed.md delete mode 100644 changelog.d/mysql-inline-string-literals.fixed.md delete mode 100644 changelog.d/mysql-signed-biginteger.changed.md delete mode 100644 changelog.d/nested-child-scope.fixed.md delete mode 100644 changelog.d/nested-controller-extends-hint.fixed.md delete mode 100644 changelog.d/new-app-commits-vendor.changed.md delete mode 100644 changelog.d/new-app-environment-settings.fixed.md delete mode 100644 changelog.d/new-app-routes-root-first.changed.md delete mode 100644 changelog.d/new-avoid-pinned-ports.fixed.md delete mode 100644 changelog.d/new-shutdown-port-probe.fixed.md delete mode 100644 changelog.d/new-writes-agent-configs.changed.md delete mode 100644 changelog.d/onerror-timeout-message.fixed.md delete mode 100644 changelog.d/oracle-beyond-64bit.fixed.md delete mode 100644 changelog.d/oracle-number-precision.changed.md delete mode 100644 changelog.d/oracle-number38-bigint.fixed.md delete mode 100644 changelog.d/order-direction-case.fixed.md delete mode 100644 changelog.d/packages-runtime-cache-offline.fixed.md delete mode 100644 changelog.d/packages-swap-recovery.fixed.md delete mode 100644 changelog.d/pathguard-exact-containment.fixed.md delete mode 100644 changelog.d/polymorphic-foreign-type.added.md delete mode 100644 changelog.d/polymorphic-schema-columns.fixed.md delete mode 100644 changelog.d/prependurl-forwarded-proto-trust.changed.md delete mode 100644 changelog.d/processqueue-boxlang-postgres.fixed.md delete mode 100644 changelog.d/processrequest-state-restore.fixed.md delete mode 100644 changelog.d/prod-error-page-status.changed.md delete mode 100644 changelog.d/project-test-runner-test-datasource.changed.md delete mode 100644 changelog.d/public-render-notfound.added.md delete mode 100644 changelog.d/query-assertions.added.md delete mode 100644 changelog.d/query-builder-date-values.changed.md delete mode 100644 changelog.d/reload-header-rustcfml-cold-request.fixed.md delete mode 100644 changelog.d/reload-redirect-keeps-subfolder.fixed.md delete mode 100644 changelog.d/reload-redirect-leading-slashes.fixed.md delete mode 100644 changelog.d/route-binding-namespaced-controllers.fixed.md delete mode 100644 changelog.d/route-index.performance.md delete mode 100644 changelog.d/rpm-rpmbuild-rewrap.fixed.md delete mode 100644 changelog.d/rustcfml-engine-cli.fixed.md delete mode 100644 changelog.d/rustcfml-engine-exclusivity-home.fixed.md delete mode 100644 changelog.d/saved-changes.added.md delete mode 100644 changelog.d/schedule-runner-wiring.added.md delete mode 100644 changelog.d/scoped-key-lookups.fixed.md delete mode 100644 changelog.d/session-cookie-upgrade-note-range.fixed.md delete mode 100644 changelog.d/sqlite-biginteger-ddl.fixed.md delete mode 100644 changelog.d/sqlite-integer-bind-64bit.fixed.md delete mode 100644 changelog.d/sqlite-updateall-date.fixed.md delete mode 100644 changelog.d/sse-resume-lasteventid.fixed.md delete mode 100644 changelog.d/start-port-in-use-hint.fixed.md delete mode 100644 changelog.d/starter-app-application-cfc-sync.changed.md delete mode 100644 changelog.d/starter-app-forgebox-version.fixed.md delete mode 100644 changelog.d/starter-app-readme-migrate.fixed.md delete mode 100644 changelog.d/starter-app-string-defaults.fixed.md delete mode 100644 changelog.d/starter-app-test-runner.fixed.md delete mode 100644 changelog.d/starter-app-wheels-cli.changed.md delete mode 100644 changelog.d/stop-live-registration.fixed.md delete mode 100644 changelog.d/strict-arguments.added.md delete mode 100644 changelog.d/subfolder-install-paths.fixed.md delete mode 100644 changelog.d/template-reads-wheels-env.security.md delete mode 100644 changelog.d/tenant-debug-flags-nonoverridable.security.md delete mode 100644 changelog.d/tenant-migrator-failed-step.fixed.md delete mode 100644 changelog.d/test-cfc-guides-version.fixed.md delete mode 100644 changelog.d/test-run-live-scope-warning.changed.md delete mode 100644 changelog.d/test-url-isolation.fixed.md delete mode 100644 changelog.d/testclient-adobe-set-cookie.fixed.md delete mode 100644 changelog.d/testclient-csrf.added.md delete mode 100644 changelog.d/testclient-test-context.fixed.md delete mode 100644 changelog.d/testcontext-path-trigger-anchor.fixed.md delete mode 100644 changelog.d/testcontext-selection-gate.fixed.md delete mode 100644 changelog.d/transaction-abort-rollback.fixed.md delete mode 100644 changelog.d/tx-advisory-locks.added.md delete mode 100644 changelog.d/uniqueidentifier-column-types.fixed.md delete mode 100644 changelog.d/upgrade-apply-backup-followups.fixed.md delete mode 100644 changelog.d/upgrade-apply-backup-outside-vendor.changed.md delete mode 100644 changelog.d/upgrade-apply-box-json-pin.fixed.md delete mode 100644 changelog.d/upgrade-check-3x.fixed.md delete mode 100644 changelog.d/upgrade-check-4x1-to-4x2.added.md delete mode 100644 changelog.d/upgrade-check-4x2-advisories.added.md delete mode 100644 changelog.d/upgrade-check-absent-hint.changed.md delete mode 100644 changelog.d/upgrade-check-application-cfc-fixes.added.md delete mode 100644 changelog.d/upgrade-check-newer-target.fixed.md delete mode 100644 changelog.d/upgrade-check-plugin-dependencies.fixed.md delete mode 100644 changelog.d/upgrade-check-wirebox-scan-targets.fixed.md delete mode 100644 changelog.d/urlrewrite-backslash-path.fixed.md delete mode 100644 changelog.d/urlrewrite-dead-format-rule.removed.md delete mode 100644 changelog.d/validation-condition-fail-closed-short.fixed.md delete mode 100644 changelog.d/validation-this-path-fail-closed.fixed.md delete mode 100644 changelog.d/verifies-failure-status.changed.md delete mode 100644 changelog.d/wheels-lookup.added.md delete mode 100644 changelog.d/where-function-left-bound.fixed.md delete mode 100644 changelog.d/where-function-value-include.fixed.md delete mode 100644 changelog.d/where-literal-boundaries.fixed.md delete mode 100644 changelog.d/where-literal-predicates.fixed.md delete mode 100644 changelog.d/where-odbc-date-literal.fixed.md delete mode 100644 changelog.d/zero-row-bulk-op-rollback.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a718394679..7712f41922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,364 @@ All historical references to "CFWheels" in this changelog have been preserved fo --- +# [4.2.0](https://github.com/wheels-dev/wheels/releases/tag/v4.2.0) => 2026-10-11 + +### 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](https://github.com/wheels-dev/wheels/issues/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. The stale-job reaper now measures from the latest heartbeat (`COALESCE(heartbeatAt, updatedAt)`), so a long job that heartbeats more often than its timeout is no longer reaped and run a second time. 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](https://github.com/wheels-dev/wheels/issues/4477)) +- 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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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=`) and exits with an error if jobs are still running, so the server can be stopped safely. `--expires=` 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](https://github.com/wheels-dev/wheels/issues/4480)) +- Recurring background jobs. + - **Defining schedules.** Define schedules in `config/schedules.cfm`: + - `schedule("digest").job("DigestJob").cron("0 7 * * MON").timezone("America/New_York")` + - `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 = ":"`, 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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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: , timeout: }`) 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=` 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 ` adds a job to the queue from the command line: now, after `--in=`, or at `--at=`, 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](https://github.com/wheels-dev/wheels/issues/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 `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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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 ` 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 +- **Behaviour change:** `/wheels/app/tests` (and `wheels test`) now **default to the `_test` database and refuse to run if it is absent**, instead of silently falling back to the primary datasource — so test writes never reach your real database. An app with no `_test` that runs its tests gets a clear refusal (HTTP 409 / a red "Test run failed before any spec ran" message) telling you to create the `_test` datasource, pass `useTestDB=false` (`wheels test --no-test-db`), or set `allowTestsAgainstPrimaryDatasource=true`. A default `wheels new` (SQLite) app already has a `_test` datasource, so `wheels new` → `wheels test` is unaffected; only apps that configure a datasource without a matching `_test` (e.g. `wheels new --no-sqlite`) need to act. Precedence: an explicit `useTestDB=true` always requires the test database; `useTestDB=false` runs against the primary datasource; and `set(allowTestsAgainstPrimaryDatasource=true)` in `config/settings.cfm` lets an **omitted** flag (e.g. from an older CLI that cannot send `useTestDB=false`) run against the primary datasource with a warning. +- 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/.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 `_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. +- 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): `"
::"` 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](https://github.com/wheels-dev/wheels/issues/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:/` +- The Wheels CLI's runtime is LuCLI 0.6.2.3. `wheels --env=` (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 .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](https://github.com/wheels-dev/wheels/pull/3835)) is removed. ([#3836](https://github.com/wheels-dev/wheels/issues/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 `_test` requirement as the built-in runner. The run uses `_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-/` in the project instead of `vendor/wheels.bak-/`. 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` 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](https://github.com/wheels-dev/wheels/issues/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 `channel` `DatabaseAdapter.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 --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](https://github.com/wheels-dev/wheels/issues/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 `, `wheels browser test` without Playwright installed, `wheels setup agents` outside a project, and `wheels generate snippets ` 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=` / `-e ` use the `lucee.json` environment profile, as `--environment=` 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 ` 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=`. `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) +- `wheels deploy` argument handling (#4417): + - 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 --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 `cfthread`s 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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/4489)) +- `heartbeat()` now shortens how long a dead worker's job goes unnoticed. Before, a job was only reclaimed after twice its timeout whether it heartbeated or not, so heartbeats changed nothing. Now a job that has heartbeated is reclaimed once its heartbeats stop for its heartbeat grace, `set(jobsHeartbeatGraceSeconds = n)` (default 300; `0` turns it off; values below 30 are raised to 30) or the job's own `this.heartbeatGrace`, even inside its timeout. Jobs that never heartbeat behave exactly as before. Heartbeat at least every grace / 3. A stopped heartbeat isn't proof the worker died: a `perform()` stuck in a long call that can't heartbeat is reclaimed too. `heartbeat()` still never extends a job's timeout, which stays the hard cap on one attempt. An exclusive job's heartbeat now renews its lease by the grace window, so a job that stops heartbeating loses its lease on the same clock it's reclaimed on. ([#4502](https://github.com/wheels-dev/wheels/issues/4502)) +- 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 `{"": {...}}` 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.claimTimeout` `ALTER` 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/`NULL` `claimTimeout` continues to fall back to the polling worker's timeout. ([#4071](https://github.com/wheels-dev/wheels/issues/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 `-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 `) 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 --filter=` now decides whether a spec file is under the spec root (`tests/specs`, or `vendor/wheels/tests/specs` with `--core`) with an exact, separator-qualified comparison of canonical paths, the same rule as the framework path guards. +- `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.` and `--filter=`. +- An unknown command (`wheels status`, `wheels bogus`) now prints `Unknown command: . 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 `_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 `_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) +- The `/wheels/*` development tools (info, routes, docs, test runners, packages, the deprecated HTTP MCP endpoint, …) now require the request's socket peer (`REMOTE_ADDR`) to be a loopback address, and are refused outright in the `production` environment. A dev server reached through Docker port publishing, where requests arrive from the Docker gateway, can allow specific peers with `set(devToolsAllowedRemoteAddresses="172.17.0.1")` (exact IPv4/IPv6 addresses or IPv4 CIDR ranges; honoured in development and testing only); publish the port on `127.0.0.1` when you do. **Behaviour change** for Docker-hosted development: `/wheels/*` returns 403 from the host until the gateway address is listed. The dev tools and `/wheels/cli` now treat only IP literals as loopback (a zone-scoped IPv6 loopback included); host names in `X-Forwarded-For` are not resolved. +- `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 `$include`d 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 `` 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 `…` with no trailing newline, or delete the empty placeholders you don't use. ([#3882](https://github.com/wheels-dev/wheels/issues/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. +- `hasManyCheckBox()` nested saves now set both keys on a new composite-key join row. Before, only the parent key was set and the insert failed with a NOT NULL error (#3884) +- `hasManyCheckBox()` now works with a surrogate-`id` join model (the shape `wheels generate model` creates). The posted keys are matched on the join model's foreign keys, so checking a box adds the row, unchecking removes it, and the box shows as checked for an existing row. Before, the first key was read as the join row's `id`, so unchecking a box could delete an unrelated join row (#3885) +- `hasManyCheckBox()` no longer throws `Element ID is undefined in ARGUMENTS` on Adobe ColdFusion when it's called without an `id` +- `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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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=` 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 ` and `wheels generate --type=app ` 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. +- On MySQL and MariaDB, a string value that Wheels writes into the SQL text instead of binding it (every value with `parameterize=false`, and literals a `where` string leaves in place, such as function arguments) is now written as a hex literal when it contains a backslash, so it reads the same whatever the `sql_mode`; other values are unchanged. A backslash in those positions is now an ordinary character, as it already was in bound values: if you wrote `\\` there to mean one backslash, write `\`. `LIKE ... ESCAPE '\\'` and `ESCAPE '\'` both still mean a single backslash. The query-builder guide now lists which `where` values are bound. +- Nested properties now only update or delete child rows that belong to the record being saved. A nested key that names a row of another record is treated as not found (it isn't changed, moved or deleted), the child's foreign key always comes from the parent's saved key, and posted primary-key values aren't kept on a new child except the join row's own foreign keys. This applies to `hasMany`, `hasOne` and nested `belongsTo` associations, including custom `joinKey` associations; keys are compared exactly (string keys such as `01` and `1` are different, and BIGINT keys are compared without rounding), and nested struct keys above 2^31 on BIGINT columns are looked up instead of being treated as new rows. A composite-key join model needs a `belongsTo` for each key column; without one, adding a row through nested properties now raises `Wheels.InvalidNestedKey` naming the column (it used to fail in the database with a NOT NULL error) +- A nested controller (`app/controllers//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](https://github.com/wheels-dev/wheels/issues/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//` 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//` 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//` is never half-written (#3902) +- Path-containment guards that confine a resolved target to a root directory — database dump output, zip extraction, package manifest mappings, dev-UI asset and offline-docs serving, and image-tag source resolution — now compare canonical paths **exactly** (case-sensitively) with a separator-qualified boundary, through a shared `wheels.PathGuard` helper. Both the root and the candidate are canonicalised the same way, so a root configured in a different case than its on-disk spelling still resolves its own descendants. The always-on lexical checks and the existing authorization gates are unchanged. +- A polymorphic association without `foreignKey` / `foreignType` now finds its columns on the child's table like other associations do: `id` / `type` when they exist, otherwise `_id` / `_type`, which `t.references(polymorphic=true)` writes when `useUnderscoreReferenceColumns` is on (the `wheels new` default). Before, those apps threw `key [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](https://github.com/wheels-dev/wheels/pull/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.")` 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) +- 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=` and `wheels engines ` / `wheels engines rustcfml ` 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) +- `findByKey()`, `exists(key=...)`, `updateByKey()` and `deleteByKey()` now combine the key with an incoming scope chain, query builder or `where` argument (AND) instead of replacing it, so a key outside the scope or `where` returns `false` or matches nothing. Lookups with no `where` are unchanged. `exists()` no longer throws in development when it gets both `key` and `where`: it checks the key within the `where`, the same in every environment. +- 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](https://github.com/wheels-dev/wheels/issues/3967), [#3966](https://github.com/wheels-dev/wheels/issues/3966), [#3968](https://github.com/wheels-dev/wheels/issues/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 && wheels stop`), and suggests a free `--port`. It used to warn and then fail with LuCLI's `Use: lucli server stop `. +- `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.json` `name` 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](https://github.com/wheels-dev/wheels/issues/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 server set them; a value set with `withCookie()` is URL-encoded once. +- A `wheels.wheelstest.TestClient` created inside a test run and pointed at the test server now sends its requests to the test application, as `$testClient()` already did. Before, a client built with `new wheels.wheelstest.TestClient()` or an app subclass reached the live application and its primary datasource while the spec code used `_test`. + - The test server is a loopback host (`localhost`, `127.x.x.x`, `[::1]`) or the host of a configured `testClientBaseUrl`. + - A client aimed at any other host is unchanged, and so is one created outside a test run, such as in a scheduled task or a script. + - Pass `testContext=false` to address the live application. +- The test-context path trigger binds the isolated test application only for a request whose path targets a runner endpoint (`/wheels/core/tests` or `/wheels/app/tests`), matched at the start of the path after stripping the query string and rejecting `..` / `//` segments (#4348) +- The test-runner application isolation (`_wheelsTest`, #3374) is now selected only in the `development` and `testing` environments. The runner path is matched against the request path. The `X-Wheels-Test-Context` header and `WHEELS_TEST_CONTEXT` cookie must carry the per-process runner secret and come from a loopback client. An isolated application refuses to start outside development and testing before any of its start-up code runs, and the core and app test runners refuse to run in production. **Existing apps:** test-context isolation needs `WHEELS_ENV` set to `development` or `testing` (the `wheels new` template sets it). Without it, specs run against the live application scope, and the runner logs an isolation-off warning and sends an `X-Wheels-Test-Isolation: off` response header. +- A request that ends with `abort` inside a Wheels transaction (`invokeWithTransaction()`, a nested call that joins it, a savepoint unit, or `withAdvisoryLock(transaction = true)`) now rolls the transaction back on every engine, as it already did on Lucee and Adobe ColdFusion. On BoxLang the writes made before the abort were kept +- `t.uniqueidentifier()` now creates a typed UUID column on every database: `UUID` on PostgreSQL, CockroachDB and H2, `CHAR(36)` on MySQL, SQLite and Oracle (SQL Server keeps `UNIQUEIDENTIFIER`). It previously emitted DDL with no type and SQL Server's `newid()` default, which failed everywhere else; `newid()` now becomes each database's own UUID generator. PostgreSQL `uuid` columns now bind as `cf_sql_other`, so finders on a `uuid` column, including ones created outside Wheels, work instead of failing with `operator does not exist: uuid = character varying`. Blank native UUID primary keys are now generated on PostgreSQL, CockroachDB and H2 too, and `generateUUID()` now always returns a version 4 UUID from a cryptographically strong source: on RustCFML it used to return a zero-filled value (#4094) +- `wheels upgrade apply` names old framework backups still inside `vendor/` (`vendor/wheels.bak-*` left by earlier versions), which make the framework log an error on every start, and says to move or delete them. When the backup can't be made (a separately mounted `vendor/`, for example), it says nothing was changed and gives the workaround: copy `vendor/wheels/` yourself, then use `--nobackup`. +- `wheels upgrade apply` now updates a CommandBox app's `box.json` `wheels-core` version (an exact version or a `^`/`~` range, in `dependencies` and `devDependencies`) to the framework it installed, and prints the change. Before, the old pin stayed, and a later `box install` copied the old framework back over the new `vendor/wheels/`, leaving a mix of both versions. Other specs (`4.x`, a channel, a `forgebox:` spec) are left alone with the exact line to change. If `box.json` isn't valid JSON, `apply` refuses before changing anything. +- `wheels upgrade check` on 3.x-era apps: a 3.0 GA app (no version in `vendor/wheels/box.json`) now reports its real version instead of `unknown` and no longer sees findings twice; checks for 3.x code that 4.x removed (WireBox, `renderPage()`, old test bases) still run after the framework swap, while checks for 4.0 default changes and for things that are only deprecated (`plugins/`, which `wheels new` scaffolds, and `paginationLinks()`) are skipped for an app already on 4.x, so a stock 4.x app reports nothing breaking; `Missing csrfCookieEncryptionSecretKey` only fires for apps that set `csrfStore="cookie"`; and the RocketUnit base class (`wheels.Test`, which still runs on 4.x) is now an advisory instead of a breaking finding, so it no longer fails the CI gate, with fix text that says to convert the tests before switching to `wheels.WheelsTest` (the `wheels.Testbox` alias, also advisory, can switch directly) (#3939) +- `wheels upgrade check --to=` now says when the CLI running it is older than ``, because its checks and app template only know releases up to its own version. It recommends upgrading the CLI first, where it used to report "All Clear" for an incomplete upgrade. This is a warning only; the exit code doesn't change. JSON output gains `cliVersion`, `cliBehindTarget` and `warnings`. The app-template drift finding now points at the upgrade guides for the target version instead of naming a 4.0-era change (#4154). +- `wheels upgrade check`: the legacy plugins check no longer passes a 3.x app just because `plugins/` is empty or missing. It also reports plugin packages declared in `box.json` (installed under `plugins/`, or `cfwheels-*` packages) and code that reads `application.wheels.plugins`. +- `wheels upgrade check`: "Direct WireBox references" now scans `public/Application.cfc`, where 3.x apps bootstrap WireBox, and skips third-party packages under `app/` (a `box.json` install path, or any folder with its own `box.json`, such as `app/lib/logbox/`). It used to miss the app's own bootstrap and flag the package's code instead. +- Under CommandBox, a request whose path contains a backslash (`\` or `%5C`) now gets a 400 instead of a 500 from the servlet container. The fix is a new "Reject a backslash in the path" rule in the shipped `public/urlrewrite.xml` (and the `wheels new` template). Existing CommandBox apps can copy that rule from a new app's `public/urlrewrite.xml` into their own, ahead of the "Wheels pretty URLs" rule. `wheels start` (LuCLI on Tomcat) already answers 400 (#4114). +- Validation `condition` / `unless` now handles the expression shapes that used to evaluate silently. A bare name, on its own or on the left of a comparison (`condition="isActive"`, `condition="status != 'draft'"`), is resolved as `this.` (a property value or a method call) when the record has it. Before, a lone name evaluated to `false` and skipped the rule, and a left-hand name was compared as its own text, so the rule always ran. A name the record doesn't have, a `this.` reference on the right of a comparison, or an expression that isn't one value or a complete `left operator right` comparison now throws `Wheels.InvalidValidationCondition`. A lone `true` or `false` evaluates to its value, and a bare word on the right of a comparison stays a string literal. The validation guide now documents the supported forms (#3928) +- Validation `condition` / `unless` expressions that use a `this.` reference now fail closed for forms the evaluator never really supported, instead of silently skipping or mis-running the rule: a `this.` reference on the **right** of a comparison (e.g. `this.total gt this.limit`), and the word-form operators **`is`**, **`and`** and **`or`** on the `this.` path, now throw `Wheels.InvalidValidationCondition` naming the supported replacement. **Upgrade note:** replace `is` with `eq` (or `==`), `and` with `&&`, `or` with `||`, and put any `this.` reference on the left side of a comparison. ([#3964](https://github.com/wheels-dev/wheels/issues/3964)) +- A `where` condition that compares a function call with a value (e.g. `ABS(id) = 5`, `UPPER(title) = 'X'`, `UPPER(title) IS NULL`) now runs, with the value bound as a parameter, instead of failing with "Array index [1] out of range" +- With `include`, a `where` condition that compares a column with a function call (e.g. `id = ABS(1)`) now qualifies the column with its table instead of failing as an ambiguous column when the joined tables share its name +- A `where` string with a quoted pattern followed by `ESCAPE '...'`, or with lowercase or mixed-case `and` / `or` between quoted values, now runs instead of failing with an "invalid hexadecimal String" error +- `where=`: a literal compared with another table's column inside a subquery (`id IN (SELECT book_id FROM accesses WHERE user_id = 1)`) now throws `Wheels.UnbindableWhereValue`, naming the condition (bound values shown as `?`, quoted values hidden) and the alternatives: `whereIn()` with a value array, or running the subquery first. Before, it failed with `Array index [1] out of range`. `Wheels.ColumnNotFound` from a `where=` now explains the rule too. `where=` accepts model columns compared with values, so a condition with no column, such as `where="1 = 0"`, isn't supported: for no rows use `whereIn()` with an empty array, and for every row leave the condition out. +- A CFML date interpolated into a finder's `where` string works again. Its ODBC escape form (`{ts '2000-01-01 00:00:00'}`, `{d '...'}`, `{t '...'}`) failed with `invalid hexadecimal String` when quoted (`where="createdAt >= '#since#'"`) or `the value for cfqueryparam cannot be determined` when bare; the date is now bound as its value (#3941) +- Fixed a data-loss bug where a `deleteAll` or `updateAll` matching zero rows, run inside a raw `transaction {}` block, silently rolled back the entire enclosing transaction on Lucee and Adobe. A zero-row bulk operation is now correctly treated as success rather than a failed operation. Wheels-managed transactions (`transaction()` / `invokeWithTransaction`) were never affected. ([#3944](https://github.com/wheels-dev/wheels/issues/3944)) + +### Security + +- `LocalDisk` storage now validates object keys per path segment instead of rejecting any key that contains `..` anywhere. A legitimate name with two dots inside a segment — `reports/q3..final.pdf`, `a..b.txt`, `v1..2` — can now be stored, while the path-traversal guard is tightened: it rejects any segment that is made only of dots and/or whitespace (`.`, `..`, `...`, `. .`, and — because Windows strips trailing dots and spaces — `.. `) and any drive-letter prefix (`C:/…`, `C:foo`). Every key stays relative to the root: a leading `/`, a trailing `/`, doubled `//` and a UNC/network prefix (`//server/share`, `\\server\share`) are normalised to a relative key under the root, and keys are never URL-decoded, so `%2e%2e%2f` stays a literal segment and can't turn into `../`. As defence in depth the resolved path is lexically canonicalised (resolving `./`, `../` and `//`) and confirmed inside the root with a trailing-separator compare, so `/root-evil` can't pass as inside `/root`. The containment check is lexical and uniform on every engine including the JVM-free RustCFML; it does not resolve symlinks (a symlink planted under `root` is still followed and trusted — see [#4020](https://github.com/wheels-dev/wheels/issues/4020)). ([#3912](https://github.com/wheels-dev/wheels/issues/3912)) +- Apps generated by `wheels deploy init` ran in development mode in production, exposing error details: the generated `config/environment.cfm` hardcoded `set(environment="development")`, so the image's `WHEELS_ENV=production` had no effect. The generated config now reads `WHEELS_ENV`: unset or empty means `development`, and any value other than `development`, `testing`, `maintenance` or `production` stops the app from starting instead of running with development-style error output. `config/settings.cfm` now reads `WHEELS_DATASOURCE` with the app name as its default. This applies to `wheels new` and the `wheels-base-template` download. `wheels info` and the `wheels test` preamble recognise the new `dataSourceName` form. **Existing apps:** templates don't reach an existing app, so replace `config/environment.cfm` with the generated version (see the Environments and Configuration guide), or hardcode `set(environment="production");` on production servers. A `WHEELS_ENV` line in `.env` wins over the process environment, so make sure a production server's `.env` doesn't say `development`. (#3946) +- A per-tenant `config` override can no longer switch on `showDebugInformation` or `enablePublicComponent` for a tenant's requests. `$get()`'s tenant-override guard already excluded `showErrorInformation`, but not the other two debug flags it is grouped with everywhere else, so a per-tenant config could expose debug output or the public-component surface for that tenant. All three debug flags are now non-overridable per-tenant. ([#3880](https://github.com/wheels-dev/wheels/issues/3880)) + +--- + # [4.1.2](https://github.com/wheels-dev/wheels/releases/tag/v4.1.2) => 2026-10-01 ### Added diff --git a/changelog.d/3896-finder-boolean-values.fixed.md b/changelog.d/3896-finder-boolean-values.fixed.md deleted file mode 100644 index c4c979cdaa..0000000000 --- a/changelog.d/3896-finder-boolean-values.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3897-mysql-boolean-bit.changed.md b/changelog.d/3897-mysql-boolean-bit.changed.md deleted file mode 100644 index d603688925..0000000000 --- a/changelog.d/3897-mysql-boolean-bit.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3897-oracle-boolean.changed.md b/changelog.d/3897-oracle-boolean.changed.md deleted file mode 100644 index 0c6536fbbb..0000000000 --- a/changelog.d/3897-oracle-boolean.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3903-rustcfml-stringindex-override.performance.md b/changelog.d/3903-rustcfml-stringindex-override.performance.md deleted file mode 100644 index c8f391e71b..0000000000 --- a/changelog.d/3903-rustcfml-stringindex-override.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3903-where-literal-scan.performance.md b/changelog.d/3903-where-literal-scan.performance.md deleted file mode 100644 index ffd3f77bea..0000000000 --- a/changelog.d/3903-where-literal-scan.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3903-where-masker-linear-rustcfml.performance.md b/changelog.d/3903-where-masker-linear-rustcfml.performance.md deleted file mode 100644 index 0e278850ba..0000000000 --- a/changelog.d/3903-where-masker-linear-rustcfml.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3906-in-list-limits.fixed.md b/changelog.d/3906-in-list-limits.fixed.md deleted file mode 100644 index 0344c68ae7..0000000000 --- a/changelog.d/3906-in-list-limits.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3907-where-in-large-integer-list.fixed.md b/changelog.d/3907-where-in-large-integer-list.fixed.md deleted file mode 100644 index fd4084b318..0000000000 --- a/changelog.d/3907-where-in-large-integer-list.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/3958-transaction-savepoint.added.md b/changelog.d/3958-transaction-savepoint.added.md deleted file mode 100644 index 71fc646070..0000000000 --- a/changelog.d/3958-transaction-savepoint.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4055-empty-string-where-values.changed.md b/changelog.d/4055-empty-string-where-values.changed.md deleted file mode 100644 index d2533fbc1f..0000000000 --- a/changelog.d/4055-empty-string-where-values.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4059-transaction-isolation-setting.added.md b/changelog.d/4059-transaction-isolation-setting.added.md deleted file mode 100644 index d7a47d9f67..0000000000 --- a/changelog.d/4059-transaction-isolation-setting.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4084-oracle-inline-dates.fixed.md b/changelog.d/4084-oracle-inline-dates.fixed.md deleted file mode 100644 index ca56d957fe..0000000000 --- a/changelog.d/4084-oracle-inline-dates.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4087-nested-small-integer-keys.fixed.md b/changelog.d/4087-nested-small-integer-keys.fixed.md deleted file mode 100644 index 9996131880..0000000000 --- a/changelog.d/4087-nested-small-integer-keys.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4093-sqlite-date-columns.fixed.md b/changelog.d/4093-sqlite-date-columns.fixed.md deleted file mode 100644 index fc31f39c30..0000000000 --- a/changelog.d/4093-sqlite-date-columns.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4095-migrator-unsupported-column-type.changed.md b/changelog.d/4095-migrator-unsupported-column-type.changed.md deleted file mode 100644 index 71129640d0..0000000000 --- a/changelog.d/4095-migrator-unsupported-column-type.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4099-channel-poll-ordering.fixed.md b/changelog.d/4099-channel-poll-ordering.fixed.md deleted file mode 100644 index 309f17b6cc..0000000000 --- a/changelog.d/4099-channel-poll-ordering.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The `channel` `DatabaseAdapter.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) diff --git a/changelog.d/4103-sqlserver-long-in-lists.added.md b/changelog.d/4103-sqlserver-long-in-lists.added.md deleted file mode 100644 index 563cb234c6..0000000000 --- a/changelog.d/4103-sqlserver-long-in-lists.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4106-seq-probe-schema-scope.fixed.md b/changelog.d/4106-seq-probe-schema-scope.fixed.md deleted file mode 100644 index 285eaaab57..0000000000 --- a/changelog.d/4106-seq-probe-schema-scope.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4134-migration-lock.changed.md b/changelog.d/4134-migration-lock.changed.md deleted file mode 100644 index 09c5834c29..0000000000 --- a/changelog.d/4134-migration-lock.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4149-controller-construction.performance.md b/changelog.d/4149-controller-construction.performance.md deleted file mode 100644 index fb6268d1b1..0000000000 --- a/changelog.d/4149-controller-construction.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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` 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) diff --git a/changelog.d/4150-memoise-inflection.performance.md b/changelog.d/4150-memoise-inflection.performance.md deleted file mode 100644 index 9d226e26cd..0000000000 --- a/changelog.d/4150-memoise-inflection.performance.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4151-linkto-urlfor-overhead.performance.md b/changelog.d/4151-linkto-urlfor-overhead.performance.md deleted file mode 100644 index 836c1cd482..0000000000 --- a/changelog.d/4151-linkto-urlfor-overhead.performance.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4151)) diff --git a/changelog.d/4168-cli-help-user-text.fixed.md b/changelog.d/4168-cli-help-user-text.fixed.md deleted file mode 100644 index 219ec681c6..0000000000 --- a/changelog.d/4168-cli-help-user-text.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels --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. diff --git a/changelog.d/4172-wide-decimal-exact-bind.fixed.md b/changelog.d/4172-wide-decimal-exact-bind.fixed.md deleted file mode 100644 index 04c09e7f0d..0000000000 --- a/changelog.d/4172-wide-decimal-exact-bind.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4174-promote-globals-metadata.performance.md b/changelog.d/4174-promote-globals-metadata.performance.md deleted file mode 100644 index d8e903ad04..0000000000 --- a/changelog.d/4174-promote-globals-metadata.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4185-pluralize-boxlang-rule-groups.fixed.md b/changelog.d/4185-pluralize-boxlang-rule-groups.fixed.md deleted file mode 100644 index e336e1d2c1..0000000000 --- a/changelog.d/4185-pluralize-boxlang-rule-groups.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4187-singularize-prefixed-analyses.changed.md b/changelog.d/4187-singularize-prefixed-analyses.changed.md deleted file mode 100644 index 96292da539..0000000000 --- a/changelog.d/4187-singularize-prefixed-analyses.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `singularize()` returns `psychoanalysis` for `psychoanalyses` instead of the misspelled `psychoanalyasis`; other `-ses` words such as `bases`, `theses` and `analyses` are unchanged (#4187) diff --git a/changelog.d/4189-singularize-databases.changed.md b/changelog.d/4189-singularize-databases.changed.md deleted file mode 100644 index e13d50a3a4..0000000000 --- a/changelog.d/4189-singularize-databases.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4194-snapshot-routes.added.md b/changelog.d/4194-snapshot-routes.added.md deleted file mode 100644 index 684ab7f75c..0000000000 --- a/changelog.d/4194-snapshot-routes.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4194)) diff --git a/changelog.d/4195-testclient-baseurl.fixed.md b/changelog.d/4195-testclient-baseurl.fixed.md deleted file mode 100644 index f61968a677..0000000000 --- a/changelog.d/4195-testclient-baseurl.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4195)) diff --git a/changelog.d/4197-advisory-lock-exclusion.fixed.md b/changelog.d/4197-advisory-lock-exclusion.fixed.md deleted file mode 100644 index c4c2d113d9..0000000000 --- a/changelog.d/4197-advisory-lock-exclusion.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4206-packages-install-alias-help.fixed.md b/changelog.d/4206-packages-install-alias-help.fixed.md deleted file mode 100644 index b0bf3fc3c9..0000000000 --- a/changelog.d/4206-packages-install-alias-help.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/4209-migrate-unlock.added.md b/changelog.d/4209-migrate-unlock.added.md deleted file mode 100644 index 54f3979499..0000000000 --- a/changelog.d/4209-migrate-unlock.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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). diff --git a/changelog.d/4219-testclient-get-timeout.added.md b/changelog.d/4219-testclient-get-timeout.added.md deleted file mode 100644 index ed492820c1..0000000000 --- a/changelog.d/4219-testclient-get-timeout.added.md +++ /dev/null @@ -1 +0,0 @@ -- `$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) diff --git a/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md b/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md deleted file mode 100644 index 1c8a6dddf1..0000000000 --- a/changelog.d/4220-sqlserver-standalone-advisory-lock.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4223-advisory-lock-tenant-datasource.changed.md b/changelog.d/4223-advisory-lock-tenant-datasource.changed.md deleted file mode 100644 index 2418edc10f..0000000000 --- a/changelog.d/4223-advisory-lock-tenant-datasource.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4232-cli-bounded-local-waits.fixed.md b/changelog.d/4232-cli-bounded-local-waits.fixed.md deleted file mode 100644 index 40d98775ab..0000000000 --- a/changelog.d/4232-cli-bounded-local-waits.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4318-sqlserver-long-date-in-lists.added.md b/changelog.d/4318-sqlserver-long-date-in-lists.added.md deleted file mode 100644 index bbaa329681..0000000000 --- a/changelog.d/4318-sqlserver-long-date-in-lists.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4326-sqlserver-datetime-equality.changed.md b/changelog.d/4326-sqlserver-datetime-equality.changed.md deleted file mode 100644 index f9b893a091..0000000000 --- a/changelog.d/4326-sqlserver-datetime-equality.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4327-sqlserver-time-conditions.fixed.md b/changelog.d/4327-sqlserver-time-conditions.fixed.md deleted file mode 100644 index f25e0d2346..0000000000 --- a/changelog.d/4327-sqlserver-time-conditions.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4370-soft-delete-dependents.changed.md b/changelog.d/4370-soft-delete-dependents.changed.md deleted file mode 100644 index 3e20fce63d..0000000000 --- a/changelog.d/4370-soft-delete-dependents.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4371-soft-delete-false-purges.changed.md b/changelog.d/4371-soft-delete-false-purges.changed.md deleted file mode 100644 index 2148613469..0000000000 --- a/changelog.d/4371-soft-delete-false-purges.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4372-reload-missing-row.changed.md b/changelog.d/4372-reload-missing-row.changed.md deleted file mode 100644 index dd7fc2f8d7..0000000000 --- a/changelog.d/4372-reload-missing-row.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4372-soft-deleted-instance.fixed.md b/changelog.d/4372-soft-deleted-instance.fixed.md deleted file mode 100644 index 2014e6503b..0000000000 --- a/changelog.d/4372-soft-deleted-instance.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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. diff --git a/changelog.d/4374-order-qualified-quoting.fixed.md b/changelog.d/4374-order-qualified-quoting.fixed.md deleted file mode 100644 index d2d5390655..0000000000 --- a/changelog.d/4374-order-qualified-quoting.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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). diff --git a/changelog.d/4376-upgrade-check-renderpage.fixed.md b/changelog.d/4376-upgrade-check-renderpage.fixed.md deleted file mode 100644 index d5f116d1e1..0000000000 --- a/changelog.d/4376-upgrade-check-renderpage.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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). diff --git a/changelog.d/4382-empty-mixins-early-return.performance.md b/changelog.d/4382-empty-mixins-early-return.performance.md deleted file mode 100644 index a5e599460b..0000000000 --- a/changelog.d/4382-empty-mixins-early-return.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4405-migrate-diff-accuracy.fixed.md b/changelog.d/4405-migrate-diff-accuracy.fixed.md deleted file mode 100644 index f7ee988987..0000000000 --- a/changelog.d/4405-migrate-diff-accuracy.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- `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) diff --git a/changelog.d/4407-db-status-applied-at.fixed.md b/changelog.d/4407-db-status-applied-at.fixed.md deleted file mode 100644 index a220fa1a38..0000000000 --- a/changelog.d/4407-db-status-applied-at.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4408-test-core-runner-error.fixed.md b/changelog.d/4408-test-core-runner-error.fixed.md deleted file mode 100644 index 2f9942f7e0..0000000000 --- a/changelog.d/4408-test-core-runner-error.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4409-cli-exit-codes.fixed.md b/changelog.d/4409-cli-exit-codes.fixed.md deleted file mode 100644 index 1d23cc0dae..0000000000 --- a/changelog.d/4409-cli-exit-codes.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels browser `, `wheels browser test` without Playwright installed, `wheels setup agents` outside a project, and `wheels generate snippets ` now exit non-zero instead of printing a refusal and exiting 0, so scripts and CI can detect the failure (#4409) diff --git a/changelog.d/4410-console-routes.fixed.md b/changelog.d/4410-console-routes.fixed.md deleted file mode 100644 index 7749056a6f..0000000000 --- a/changelog.d/4410-console-routes.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The console's `/routes` command no longer fails with a syntax error; it lists routes as `pattern -> controller#action` (#4410) diff --git a/changelog.d/4411-start-no-side-effects.fixed.md b/changelog.d/4411-start-no-side-effects.fixed.md deleted file mode 100644 index 88333e4a9e..0000000000 --- a/changelog.d/4411-start-no-side-effects.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- `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=` / `-e ` use the `lucee.json` environment profile, as `--environment=` already did. LuCLI takes `--env` / `-e` as its own root option, so `wheels start` now forwards the value (#4412) diff --git a/changelog.d/4413-setup-h2-datasources.fixed.md b/changelog.d/4413-setup-h2-datasources.fixed.md deleted file mode 100644 index ff4069ca5e..0000000000 --- a/changelog.d/4413-setup-h2-datasources.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/4414-controller-no-actions.fixed.md b/changelog.d/4414-controller-no-actions.fixed.md deleted file mode 100644 index caa46e8774..0000000000 --- a/changelog.d/4414-controller-no-actions.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels generate controller ` with no actions now writes an empty controller instead of an `index()` stub with no matching view, which 500s at runtime (#4414) diff --git a/changelog.d/4415-deploy-builder-options.fixed.md b/changelog.d/4415-deploy-builder-options.fixed.md deleted file mode 100644 index 3a180a7b8c..0000000000 --- a/changelog.d/4415-deploy-builder-options.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- `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=`. `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) diff --git a/changelog.d/4417-deploy-arg-handling.fixed.md b/changelog.d/4417-deploy-arg-handling.fixed.md deleted file mode 100644 index caaa69dce8..0000000000 --- a/changelog.d/4417-deploy-arg-handling.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -- `wheels deploy` argument handling (#4417): - - 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`. diff --git a/changelog.d/4418-cli-help-text.fixed.md b/changelog.d/4418-cli-help-text.fixed.md deleted file mode 100644 index 226f489e40..0000000000 --- a/changelog.d/4418-cli-help-text.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 --yes`. The seed-data snippet hint says to move the files into `app/db/` before running `wheels seed` (#4418) diff --git a/changelog.d/4429-request-cache-cross-model.fixed.md b/changelog.d/4429-request-cache-cross-model.fixed.md deleted file mode 100644 index 79a1be45ad..0000000000 --- a/changelog.d/4429-request-cache-cross-model.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4433-cookie-flash-same-request.fixed.md b/changelog.d/4433-cookie-flash-same-request.fixed.md deleted file mode 100644 index 81d27b5d93..0000000000 --- a/changelog.d/4433-cookie-flash-same-request.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4434-diff-subfolders-seed-snippet.fixed.md b/changelog.d/4434-diff-subfolders-seed-snippet.fixed.md deleted file mode 100644 index 0e770bd994..0000000000 --- a/changelog.d/4434-diff-subfolders-seed-snippet.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- 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) diff --git a/changelog.d/4448-injector-cfthread-resolving.fixed.md b/changelog.d/4448-injector-cfthread-resolving.fixed.md deleted file mode 100644 index c4f7d0c84b..0000000000 --- a/changelog.d/4448-injector-cfthread-resolving.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The dependency injector no longer throws a spurious `Wheels.DI.CircularDependency` when two of a request's `cfthread`s 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) diff --git a/changelog.d/4470-public-subfolder-mappings.fixed.md b/changelog.d/4470-public-subfolder-mappings.fixed.md deleted file mode 100644 index 084f62f6a0..0000000000 --- a/changelog.d/4470-public-subfolder-mappings.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/4475-processqueue-through-worker.fixed.md b/changelog.d/4475-processqueue-through-worker.fixed.md deleted file mode 100644 index 44ec3e4c07..0000000000 --- a/changelog.d/4475-processqueue-through-worker.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4475)) diff --git a/changelog.d/4476-job-claim-fencing.fixed.md b/changelog.d/4476-job-claim-fencing.fixed.md deleted file mode 100644 index 6015f6d3f5..0000000000 --- a/changelog.d/4476-job-claim-fencing.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4476)) diff --git a/changelog.d/4477-job-heartbeat.added.md b/changelog.d/4477-job-heartbeat.added.md deleted file mode 100644 index 31b8271b68..0000000000 --- a/changelog.d/4477-job-heartbeat.added.md +++ /dev/null @@ -1,2 +0,0 @@ -- Background jobs can call `heartbeat()` from inside `perform()` to signal that they are still running. The stale-job reaper now measures from the latest heartbeat (`COALESCE(heartbeatAt, updatedAt)`), so a long job that heartbeats more often than its timeout is no longer reaped and run a second time. 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](https://github.com/wheels-dev/wheels/issues/4477)) -- 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](https://github.com/wheels-dev/wheels/issues/4477)) diff --git a/changelog.d/4478-job-unique-key.added.md b/changelog.d/4478-job-unique-key.added.md deleted file mode 100644 index 75490061eb..0000000000 --- a/changelog.d/4478-job-unique-key.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4478)) diff --git a/changelog.d/4479-exclusive-jobs-lease-lock.added.md b/changelog.d/4479-exclusive-jobs-lease-lock.added.md deleted file mode 100644 index 58fe2bc9eb..0000000000 --- a/changelog.d/4479-exclusive-jobs-lease-lock.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4479)) diff --git a/changelog.d/4480-job-hosts-cap-drain.added.md b/changelog.d/4480-job-hosts-cap-drain.added.md deleted file mode 100644 index 31723999d5..0000000000 --- a/changelog.d/4480-job-hosts-cap-drain.added.md +++ /dev/null @@ -1,5 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4480)) diff --git a/changelog.d/4480-jobs-drain-cli.added.md b/changelog.d/4480-jobs-drain-cli.added.md deleted file mode 100644 index 4e68da6b30..0000000000 --- a/changelog.d/4480-jobs-drain-cli.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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=`) and exits with an error if jobs are still running, so the server can be stopped safely. `--expires=` 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](https://github.com/wheels-dev/wheels/issues/4480)) diff --git a/changelog.d/4481-job-schedules.added.md b/changelog.d/4481-job-schedules.added.md deleted file mode 100644 index 20bcac7fa8..0000000000 --- a/changelog.d/4481-job-schedules.added.md +++ /dev/null @@ -1,11 +0,0 @@ -- Recurring background jobs. - - **Defining schedules.** Define schedules in `config/schedules.cfm`: - - `schedule("digest").job("DigestJob").cron("0 7 * * MON").timezone("America/New_York")` - - `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 = ":"`, 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](https://github.com/wheels-dev/wheels/issues/4481)) diff --git a/changelog.d/4482-jobs-tick-route.added.md b/changelog.d/4482-jobs-tick-route.added.md deleted file mode 100644 index 10380d8a92..0000000000 --- a/changelog.d/4482-jobs-tick-route.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4482)) diff --git a/changelog.d/4483-job-lifecycle-hooks.added.md b/changelog.d/4483-job-lifecycle-hooks.added.md deleted file mode 100644 index ea6d1899a3..0000000000 --- a/changelog.d/4483-job-lifecycle-hooks.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4483)) diff --git a/changelog.d/4484-jobs-install.added.md b/changelog.d/4484-jobs-install.added.md deleted file mode 100644 index b244243143..0000000000 --- a/changelog.d/4484-jobs-install.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4484)) diff --git a/changelog.d/4489-unique-key-sqlserver-compat.fixed.md b/changelog.d/4489-unique-key-sqlserver-compat.fixed.md deleted file mode 100644 index 64756a922d..0000000000 --- a/changelog.d/4489-unique-key-sqlserver-compat.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/4489)) diff --git a/changelog.d/4502-heartbeat-grace.fixed.md b/changelog.d/4502-heartbeat-grace.fixed.md deleted file mode 100644 index c2ba7f87e7..0000000000 --- a/changelog.d/4502-heartbeat-grace.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `heartbeat()` now shortens how long a dead worker's job goes unnoticed. Before, a job was only reclaimed after twice its timeout whether it heartbeated or not, so heartbeats changed nothing. Now a job that has heartbeated is reclaimed once its heartbeats stop for its heartbeat grace, `set(jobsHeartbeatGraceSeconds = n)` (default 300; `0` turns it off; values below 30 are raised to 30) or the job's own `this.heartbeatGrace`, even inside its timeout. Jobs that never heartbeat behave exactly as before. Heartbeat at least every grace / 3. A stopped heartbeat isn't proof the worker died: a `perform()` stuck in a long call that can't heartbeat is reclaimed too. `heartbeat()` still never extends a job's timeout, which stays the hard cap on one attempt. An exclusive job's heartbeat now renews its lease by the grace window, so a job that stops heartbeating loses its lease on the same clock it's reclaimed on. ([#4502](https://github.com/wheels-dev/wheels/issues/4502)) diff --git a/changelog.d/4525-jobclock-rustcfml-local-time.fixed.md b/changelog.d/4525-jobclock-rustcfml-local-time.fixed.md deleted file mode 100644 index f6b8b6a3ba..0000000000 --- a/changelog.d/4525-jobclock-rustcfml-local-time.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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). diff --git a/changelog.d/adobe-nested-transaction-isolation.fixed.md b/changelog.d/adobe-nested-transaction-isolation.fixed.md deleted file mode 100644 index 2db671b99b..0000000000 --- a/changelog.d/adobe-nested-transaction-isolation.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/adobe-raw-transaction-detection.fixed.md b/changelog.d/adobe-raw-transaction-detection.fixed.md deleted file mode 100644 index ef77e13acf..0000000000 --- a/changelog.d/adobe-raw-transaction-detection.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/after-commit-callbacks.added.md b/changelog.d/after-commit-callbacks.added.md deleted file mode 100644 index ee4981a01b..0000000000 --- a/changelog.d/after-commit-callbacks.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/after-rollback-timing.changed.md b/changelog.d/after-rollback-timing.changed.md deleted file mode 100644 index 8119c5f816..0000000000 --- a/changelog.d/after-rollback-timing.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/agent-readable-dev-errors.added.md b/changelog.d/agent-readable-dev-errors.added.md deleted file mode 100644 index 4e8b5357d6..0000000000 --- a/changelog.d/agent-readable-dev-errors.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/agents-md-mcp-setup.fixed.md b/changelog.d/agents-md-mcp-setup.fixed.md deleted file mode 100644 index 524268eb4e..0000000000 --- a/changelog.d/agents-md-mcp-setup.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/api-resource-controller-guards.fixed.md b/changelog.d/api-resource-controller-guards.fixed.md deleted file mode 100644 index f529e9d9eb..0000000000 --- a/changelog.d/api-resource-controller-guards.fixed.md +++ /dev/null @@ -1,6 +0,0 @@ -- `wheels generate api-resource` controllers: - - answer 400 with a clear message when the body has no `{"": {...}}` 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) diff --git a/changelog.d/api-resource-route-outside-marked-blocks.fixed.md b/changelog.d/api-resource-route-outside-marked-blocks.fixed.md deleted file mode 100644 index 9df0e1181e..0000000000 --- a/changelog.d/api-resource-route-outside-marked-blocks.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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()`. diff --git a/changelog.d/app-data-cache.added.md b/changelog.d/app-data-cache.added.md deleted file mode 100644 index 0302023844..0000000000 --- a/changelog.d/app-data-cache.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/app-test-default-test-db.changed.md b/changelog.d/app-test-default-test-db.changed.md deleted file mode 100644 index 4e87cb38ec..0000000000 --- a/changelog.d/app-test-default-test-db.changed.md +++ /dev/null @@ -1 +0,0 @@ -- **Behaviour change:** `/wheels/app/tests` (and `wheels test`) now **default to the `_test` database and refuse to run if it is absent**, instead of silently falling back to the primary datasource — so test writes never reach your real database. An app with no `_test` that runs its tests gets a clear refusal (HTTP 409 / a red "Test run failed before any spec ran" message) telling you to create the `_test` datasource, pass `useTestDB=false` (`wheels test --no-test-db`), or set `allowTestsAgainstPrimaryDatasource=true`. A default `wheels new` (SQLite) app already has a `_test` datasource, so `wheels new` → `wheels test` is unaffected; only apps that configure a datasource without a matching `_test` (e.g. `wheels new --no-sqlite`) need to act. Precedence: an explicit `useTestDB=true` always requires the test database; `useTestDB=false` runs against the primary datasource; and `set(allowTestsAgainstPrimaryDatasource=true)` in `config/settings.cfm` lets an **omitted** flag (e.g. from an older CLI that cannot send `useTestDB=false`) run against the primary datasource with a warning. diff --git a/changelog.d/app-test-run-timeout.changed.md b/changelog.d/app-test-run-timeout.changed.md deleted file mode 100644 index e8c4faa8ba..0000000000 --- a/changelog.d/app-test-run-timeout.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/association-jointype-left.fixed.md b/changelog.d/association-jointype-left.fixed.md deleted file mode 100644 index 057369b67f..0000000000 --- a/changelog.d/association-jointype-left.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/base-template-consumer-ai-docs.fixed.md b/changelog.d/base-template-consumer-ai-docs.fixed.md deleted file mode 100644 index fbe612376c..0000000000 --- a/changelog.d/base-template-consumer-ai-docs.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/bigint-column-types.fixed.md b/changelog.d/bigint-column-types.fixed.md deleted file mode 100644 index f7cec0716c..0000000000 --- a/changelog.d/bigint-column-types.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/browser-classpath-separator.fixed.md b/changelog.d/browser-classpath-separator.fixed.md deleted file mode 100644 index 14ea91ffe4..0000000000 --- a/changelog.d/browser-classpath-separator.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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 (`:`). diff --git a/changelog.d/browser-install-alias.changed.md b/changelog.d/browser-install-alias.changed.md deleted file mode 100644 index 635c862864..0000000000 --- a/changelog.d/browser-install-alias.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/browser-launch-failure-not-a-pass.changed.md b/changelog.d/browser-launch-failure-not-a-pass.changed.md deleted file mode 100644 index 926ae77480..0000000000 --- a/changelog.d/browser-launch-failure-not-a-pass.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/browser-setup-launch-probe.fixed.md b/changelog.d/browser-setup-launch-probe.fixed.md deleted file mode 100644 index 79c30e0b98..0000000000 --- a/changelog.d/browser-setup-launch-probe.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/bulk-batch-parameter-limit.fixed.md b/changelog.d/bulk-batch-parameter-limit.fixed.md deleted file mode 100644 index 97b76e13ec..0000000000 --- a/changelog.d/bulk-batch-parameter-limit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/bulk-insert-transaction.changed.md b/changelog.d/bulk-insert-transaction.changed.md deleted file mode 100644 index d33f21fbc9..0000000000 --- a/changelog.d/bulk-insert-transaction.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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 diff --git a/changelog.d/callbacks-false-commit-callbacks.fixed.md b/changelog.d/callbacks-false-commit-callbacks.fixed.md deleted file mode 100644 index 1ec7f37235..0000000000 --- a/changelog.d/callbacks-false-commit-callbacks.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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` diff --git a/changelog.d/char-column-type.fixed.md b/changelog.d/char-column-type.fixed.md deleted file mode 100644 index a172e2d8a8..0000000000 --- a/changelog.d/char-column-type.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/claimtimeout-alter-backoff.fixed.md b/changelog.d/claimtimeout-alter-backoff.fixed.md deleted file mode 100644 index b4ee61e903..0000000000 --- a/changelog.d/claimtimeout-alter-backoff.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The background-job table upgrade no longer re-fires the `wheels_jobs.claimTimeout` `ALTER` 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/`NULL` `claimTimeout` continues to fall back to the polling worker's timeout. ([#4071](https://github.com/wheels-dev/wheels/issues/4071)) diff --git a/changelog.d/clear-change-information-property.fixed.md b/changelog.d/clear-change-information-property.fixed.md deleted file mode 100644 index 38aacc826d..0000000000 --- a/changelog.d/clear-change-information-property.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/cli-apply-report-new-help.fixed.md b/changelog.d/cli-apply-report-new-help.fixed.md deleted file mode 100644 index 8c44d1fb61..0000000000 --- a/changelog.d/cli-apply-report-new-help.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- `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. diff --git a/changelog.d/cli-dev-checkout-version.fixed.md b/changelog.d/cli-dev-checkout-version.fixed.md deleted file mode 100644 index 1cf322702e..0000000000 --- a/changelog.d/cli-dev-checkout-version.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A source checkout of the CLI no longer prints the raw `@build.version@` token. `wheels version`, `wheels info`, `wheels --help` and `wheels console` show `-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) diff --git a/changelog.d/cli-generate-outside-project.fixed.md b/changelog.d/cli-generate-outside-project.fixed.md deleted file mode 100644 index 9f74e37ed4..0000000000 --- a/changelog.d/cli-generate-outside-project.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/cli-setup-help.fixed.md b/changelog.d/cli-setup-help.fixed.md deleted file mode 100644 index 117b85c8c5..0000000000 --- a/changelog.d/cli-setup-help.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels setup --help` shows the setup command's own help again, instead of the general command list. diff --git a/changelog.d/cli-test-default-scope-empty.fixed.md b/changelog.d/cli-test-default-scope-empty.fixed.md deleted file mode 100644 index 1d3893d707..0000000000 --- a/changelog.d/cli-test-default-scope-empty.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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 `) 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). diff --git a/changelog.d/cli-test-filter-exact-containment.fixed.md b/changelog.d/cli-test-filter-exact-containment.fixed.md deleted file mode 100644 index cbc4fffe45..0000000000 --- a/changelog.d/cli-test-filter-exact-containment.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels test --filter=` now decides whether a spec file is under the spec root (`tests/specs`, or `vendor/wheels/tests/specs` with `--core`) with an exact, separator-qualified comparison of canonical paths, the same rule as the framework path guards. diff --git a/changelog.d/cli-test-help-and-page-pool.fixed.md b/changelog.d/cli-test-help-and-page-pool.fixed.md deleted file mode 100644 index 444d3c242c..0000000000 --- a/changelog.d/cli-test-help-and-page-pool.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- `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). diff --git a/changelog.d/cli-test-no-server-hint.fixed.md b/changelog.d/cli-test-no-server-hint.fixed.md deleted file mode 100644 index 774dfecb22..0000000000 --- a/changelog.d/cli-test-no-server-hint.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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). diff --git a/changelog.d/cli-test-path-scope.fixed.md b/changelog.d/cli-test-path-scope.fixed.md deleted file mode 100644 index bc4fd587e3..0000000000 --- a/changelog.d/cli-test-path-scope.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- `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.` and `--filter=`. diff --git a/changelog.d/cli-unknown-command.fixed.md b/changelog.d/cli-unknown-command.fixed.md deleted file mode 100644 index 80889107d8..0000000000 --- a/changelog.d/cli-unknown-command.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- An unknown command (`wheels status`, `wheels bogus`) now prints `Unknown command: . 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) diff --git a/changelog.d/clock-seam-travelto.added.md b/changelog.d/clock-seam-travelto.added.md deleted file mode 100644 index ab0e4e238a..0000000000 --- a/changelog.d/clock-seam-travelto.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/cockroachdb-string-length.fixed.md b/changelog.d/cockroachdb-string-length.fixed.md deleted file mode 100644 index c5ab682af3..0000000000 --- a/changelog.d/cockroachdb-string-length.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/codegen-migration-empty-defaults.fixed.md b/changelog.d/codegen-migration-empty-defaults.fixed.md deleted file mode 100644 index 73b121dcfd..0000000000 --- a/changelog.d/codegen-migration-empty-defaults.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/console-bare-exit.fixed.md b/changelog.d/console-bare-exit.fixed.md deleted file mode 100644 index 70fe38675a..0000000000 --- a/changelog.d/console-bare-exit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/consumer-claude-md-split.changed.md b/changelog.d/consumer-claude-md-split.changed.md deleted file mode 100644 index c284271dd1..0000000000 --- a/changelog.d/consumer-claude-md-split.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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/.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) diff --git a/changelog.d/controller-global-helpers-init-time.changed.md b/changelog.d/controller-global-helpers-init-time.changed.md deleted file mode 100644 index 419c977398..0000000000 --- a/changelog.d/controller-global-helpers-init-time.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/controller-global-helpers-per-instance.fixed.md b/changelog.d/controller-global-helpers-per-instance.fixed.md deleted file mode 100644 index 7f7a776bde..0000000000 --- a/changelog.d/controller-global-helpers-per-instance.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/core-test-datasource-default.changed.md b/changelog.d/core-test-datasource-default.changed.md deleted file mode 100644 index 0c7a3d76a3..0000000000 --- a/changelog.d/core-test-datasource-default.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/core-test-datasource-default.fixed.md b/changelog.d/core-test-datasource-default.fixed.md deleted file mode 100644 index aee2863a0e..0000000000 --- a/changelog.d/core-test-datasource-default.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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 `_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 `_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. diff --git a/changelog.d/core-test-runner-test-datasource.changed.md b/changelog.d/core-test-runner-test-datasource.changed.md deleted file mode 100644 index e1e9edbdb2..0000000000 --- a/changelog.d/core-test-runner-test-datasource.changed.md +++ /dev/null @@ -1 +0,0 @@ -- When a run of the framework's own test suite (`/wheels/core/tests`) would use the app's primary datasource, it now needs `_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. diff --git a/changelog.d/csrf-cookie-base64url.fixed.md b/changelog.d/csrf-cookie-base64url.fixed.md deleted file mode 100644 index f00835715e..0000000000 --- a/changelog.d/csrf-cookie-base64url.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/csrf-cookie-same-request-read.fixed.md b/changelog.d/csrf-cookie-same-request-read.fixed.md deleted file mode 100644 index 98b7c262b6..0000000000 --- a/changelog.d/csrf-cookie-same-request-read.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/csrf-cookie-store-adobe.fixed.md b/changelog.d/csrf-cookie-store-adobe.fixed.md deleted file mode 100644 index e61215a089..0000000000 --- a/changelog.d/csrf-cookie-store-adobe.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/csrf-header-any-request.changed.md b/changelog.d/csrf-header-any-request.changed.md deleted file mode 100644 index 90acb510f0..0000000000 --- a/changelog.d/csrf-header-any-request.changed.md +++ /dev/null @@ -1,2 +0,0 @@ -- 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) diff --git a/changelog.d/datasource-single-quotes.fixed.md b/changelog.d/datasource-single-quotes.fixed.md deleted file mode 100644 index 8cf32dbe98..0000000000 --- a/changelog.d/datasource-single-quotes.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/dependent-callbacks.fixed.md b/changelog.d/dependent-callbacks.fixed.md deleted file mode 100644 index af54914548..0000000000 --- a/changelog.d/dependent-callbacks.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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 diff --git a/changelog.d/deploy-accessory-port-bind.changed.md b/changelog.d/deploy-accessory-port-bind.changed.md deleted file mode 100644 index 236b87fb42..0000000000 --- a/changelog.d/deploy-accessory-port-bind.changed.md +++ /dev/null @@ -1 +0,0 @@ -- **Breaking:** `wheels deploy` now requires an accessory `port:` to name a bind address (the rule Kamal's unreleased `main` adopted): `"
::"` 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) diff --git a/changelog.d/deploy-app-volumes.added.md b/changelog.d/deploy-app-volumes.added.md deleted file mode 100644 index ed224083b2..0000000000 --- a/changelog.d/deploy-app-volumes.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/deploy-boot-limit-wait.fixed.md b/changelog.d/deploy-boot-limit-wait.fixed.md deleted file mode 100644 index 0e23c4925a..0000000000 --- a/changelog.d/deploy-boot-limit-wait.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/deploy-followups-3914.fixed.md b/changelog.d/deploy-followups-3914.fixed.md deleted file mode 100644 index 6c22166767..0000000000 --- a/changelog.d/deploy-followups-3914.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/deploy-migrate-step.added.md b/changelog.d/deploy-migrate-step.added.md deleted file mode 100644 index 9df9128aaa..0000000000 --- a/changelog.d/deploy-migrate-step.added.md +++ /dev/null @@ -1,2 +0,0 @@ -- `wheels deploy` can run the database migrations once per deploy, on one host. Add `migrate: true` (or `migrate: {host: , timeout: }`) 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). diff --git a/changelog.d/deploy-volumes-absolute-host.fixed.md b/changelog.d/deploy-volumes-absolute-host.fixed.md deleted file mode 100644 index 82ad0e77f8..0000000000 --- a/changelog.d/deploy-volumes-absolute-host.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/devtools-loopback-peer.fixed.md b/changelog.d/devtools-loopback-peer.fixed.md deleted file mode 100644 index c2896a216b..0000000000 --- a/changelog.d/devtools-loopback-peer.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The `/wheels/*` development tools (info, routes, docs, test runners, packages, the deprecated HTTP MCP endpoint, …) now require the request's socket peer (`REMOTE_ADDR`) to be a loopback address, and are refused outright in the `production` environment. A dev server reached through Docker port publishing, where requests arrive from the Docker gateway, can allow specific peers with `set(devToolsAllowedRemoteAddresses="172.17.0.1")` (exact IPv4/IPv6 addresses or IPv4 CIDR ranges; honoured in development and testing only); publish the port on `127.0.0.1` when you do. **Behaviour change** for Docker-hosted development: `/wheels/*` returns 403 from the host until the gateway address is listed. The dev tools and `/wheels/cli` now treat only IP literals as loopback (a zone-scoped IPv6 loopback included); host names in `X-Forwarded-For` are not resolved. diff --git a/changelog.d/docs-fetch-mount-offline.fixed.md b/changelog.d/docs-fetch-mount-offline.fixed.md deleted file mode 100644 index 0eaa1311ca..0000000000 --- a/changelog.d/docs-fetch-mount-offline.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/empty-default-non-string-columns.changed.md b/changelog.d/empty-default-non-string-columns.changed.md deleted file mode 100644 index d50f487ba6..0000000000 --- a/changelog.d/empty-default-non-string-columns.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/escape-for-like.added.md b/changelog.d/escape-for-like.added.md deleted file mode 100644 index 5013ef52d9..0000000000 --- a/changelog.d/escape-for-like.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/event-template-whitespace.fixed.md b/changelog.d/event-template-whitespace.fixed.md deleted file mode 100644 index 815edd7091..0000000000 --- a/changelog.d/event-template-whitespace.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 `$include`d 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 `` 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 `…` with no trailing newline, or delete the empty placeholders you don't use. ([#3882](https://github.com/wheels-dev/wheels/issues/3882)) diff --git a/changelog.d/f23-testclient-from-address.added.md b/changelog.d/f23-testclient-from-address.added.md deleted file mode 100644 index 6e47fc79db..0000000000 --- a/changelog.d/f23-testclient-from-address.added.md +++ /dev/null @@ -1 +0,0 @@ -- `$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) diff --git a/changelog.d/findall-returnas-structs-array.changed.md b/changelog.d/findall-returnas-structs-array.changed.md deleted file mode 100644 index 3a78216243..0000000000 --- a/changelog.d/findall-returnas-structs-array.changed.md +++ /dev/null @@ -1,2 +0,0 @@ -- **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) diff --git a/changelog.d/forget-cached-queries.added.md b/changelog.d/forget-cached-queries.added.md deleted file mode 100644 index 451a488bc6..0000000000 --- a/changelog.d/forget-cached-queries.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/framework-install-command.added.md b/changelog.d/framework-install-command.added.md deleted file mode 100644 index 913440e745..0000000000 --- a/changelog.d/framework-install-command.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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=` must match the bundled version (no downloads) diff --git a/changelog.d/generate-auth-api-json.fixed.md b/changelog.d/generate-auth-api-json.fixed.md deleted file mode 100644 index fd6a91fb73..0000000000 --- a/changelog.d/generate-auth-api-json.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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`). diff --git a/changelog.d/generate-auth-token-index-docs.changed.md b/changelog.d/generate-auth-token-index-docs.changed.md deleted file mode 100644 index b6a6033187..0000000000 --- a/changelog.d/generate-auth-token-index-docs.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/generate-migration-property-hint.changed.md b/changelog.d/generate-migration-property-hint.changed.md deleted file mode 100644 index edc729eb3f..0000000000 --- a/changelog.d/generate-migration-property-hint.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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`. diff --git a/changelog.d/generate-next-steps-reload.fixed.md b/changelog.d/generate-next-steps-reload.fixed.md deleted file mode 100644 index 7650f913d9..0000000000 --- a/changelog.d/generate-next-steps-reload.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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). diff --git a/changelog.d/guides-link-latest-4-2.fixed.md b/changelog.d/guides-link-latest-4-2.fixed.md deleted file mode 100644 index 3d21977b6a..0000000000 --- a/changelog.d/guides-link-latest-4-2.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/guides-links-follow-version.changed.md b/changelog.d/guides-links-follow-version.changed.md deleted file mode 100644 index 701ca6f055..0000000000 --- a/changelog.d/guides-links-follow-version.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/hashed-key-order.fixed.md b/changelog.d/hashed-key-order.fixed.md deleted file mode 100644 index a39ade0603..0000000000 --- a/changelog.d/hashed-key-order.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/hasmanycheckbox-join-models.fixed.md b/changelog.d/hasmanycheckbox-join-models.fixed.md deleted file mode 100644 index f981b67283..0000000000 --- a/changelog.d/hasmanycheckbox-join-models.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- `hasManyCheckBox()` nested saves now set both keys on a new composite-key join row. Before, only the parent key was set and the insert failed with a NOT NULL error (#3884) -- `hasManyCheckBox()` now works with a surrogate-`id` join model (the shape `wheels generate model` creates). The posted keys are matched on the join model's foreign keys, so checking a box adds the row, unchecking removes it, and the box shows as checked for an existing row. Before, the first key was read as the join row's `id`, so unchecking a box could delete an unrelated join row (#3885) -- `hasManyCheckBox()` no longer throws `Element ID is undefined in ARGUMENTS` on Adobe ColdFusion when it's called without an `id` diff --git a/changelog.d/is-safe-redirect-url.added.md b/changelog.d/is-safe-redirect-url.added.md deleted file mode 100644 index 7d6fdc2cc0..0000000000 --- a/changelog.d/is-safe-redirect-url.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/job-claim-timeout.changed.md b/changelog.d/job-claim-timeout.changed.md deleted file mode 100644 index 906b483f0c..0000000000 --- a/changelog.d/job-claim-timeout.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/3989)) diff --git a/changelog.d/job-cron-steps.changed.md b/changelog.d/job-cron-steps.changed.md deleted file mode 100644 index 805f8d7234..0000000000 --- a/changelog.d/job-cron-steps.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/job-defer-cross-datasource.changed.md b/changelog.d/job-defer-cross-datasource.changed.md deleted file mode 100644 index 77c89e2dc0..0000000000 --- a/changelog.d/job-defer-cross-datasource.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/job-enqueue-duplicate-data.fixed.md b/changelog.d/job-enqueue-duplicate-data.fixed.md deleted file mode 100644 index 9461d66933..0000000000 --- a/changelog.d/job-enqueue-duplicate-data.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/3887)) diff --git a/changelog.d/job-enqueue-failure-throws.changed.md b/changelog.d/job-enqueue-failure-throws.changed.md deleted file mode 100644 index 5eb4a60e51..0000000000 --- a/changelog.d/job-enqueue-failure-throws.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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)`. diff --git a/changelog.d/job-maxretries-docs.fixed.md b/changelog.d/job-maxretries-docs.fixed.md deleted file mode 100644 index fa759b71dc..0000000000 --- a/changelog.d/job-maxretries-docs.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/job-stale-processing-reaper.fixed.md b/changelog.d/job-stale-processing-reaper.fixed.md deleted file mode 100644 index 97b73bc1e6..0000000000 --- a/changelog.d/job-stale-processing-reaper.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/3888)) diff --git a/changelog.d/job-survives-rollback.added.md b/changelog.d/job-survives-rollback.added.md deleted file mode 100644 index 698cb93409..0000000000 --- a/changelog.d/job-survives-rollback.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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 diff --git a/changelog.d/job-utc-clock.changed.md b/changelog.d/job-utc-clock.changed.md deleted file mode 100644 index 71d5a65278..0000000000 --- a/changelog.d/job-utc-clock.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/jobs-enqueue-cli.added.md b/changelog.d/jobs-enqueue-cli.added.md deleted file mode 100644 index a29b677cc7..0000000000 --- a/changelog.d/jobs-enqueue-cli.added.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels jobs enqueue ` adds a job to the queue from the command line: now, after `--in=`, or at `--at=`, 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. diff --git a/changelog.d/jobs-schema-missing-in-transaction.fixed.md b/changelog.d/jobs-schema-missing-in-transaction.fixed.md deleted file mode 100644 index 609b331c4f..0000000000 --- a/changelog.d/jobs-schema-missing-in-transaction.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/jobs-stop-when-empty.added.md b/changelog.d/jobs-stop-when-empty.added.md deleted file mode 100644 index 744b5524f0..0000000000 --- a/changelog.d/jobs-stop-when-empty.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/jobs-tick-settings.added.md b/changelog.d/jobs-tick-settings.added.md deleted file mode 100644 index 51aa4fc356..0000000000 --- a/changelog.d/jobs-tick-settings.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/jobs-work-timeout.fixed.md b/changelog.d/jobs-work-timeout.fixed.md deleted file mode 100644 index 5cd12183ad..0000000000 --- a/changelog.d/jobs-work-timeout.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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=` option caps it. (#4517) diff --git a/changelog.d/json-reporter-plain-errors.fixed.md b/changelog.d/json-reporter-plain-errors.fixed.md deleted file mode 100644 index dd19a32e57..0000000000 --- a/changelog.d/json-reporter-plain-errors.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/localdisk-key-validation.security.md b/changelog.d/localdisk-key-validation.security.md deleted file mode 100644 index 103ac0d786..0000000000 --- a/changelog.d/localdisk-key-validation.security.md +++ /dev/null @@ -1 +0,0 @@ -- `LocalDisk` storage now validates object keys per path segment instead of rejecting any key that contains `..` anywhere. A legitimate name with two dots inside a segment — `reports/q3..final.pdf`, `a..b.txt`, `v1..2` — can now be stored, while the path-traversal guard is tightened: it rejects any segment that is made only of dots and/or whitespace (`.`, `..`, `...`, `. .`, and — because Windows strips trailing dots and spaces — `.. `) and any drive-letter prefix (`C:/…`, `C:foo`). Every key stays relative to the root: a leading `/`, a trailing `/`, doubled `//` and a UNC/network prefix (`//server/share`, `\\server\share`) are normalised to a relative key under the root, and keys are never URL-decoded, so `%2e%2e%2f` stays a literal segment and can't turn into `../`. As defence in depth the resolved path is lexically canonicalised (resolving `./`, `../` and `//`) and confirmed inside the root with a trailing-separator compare, so `/root-evil` can't pass as inside `/root`. The containment check is lexical and uniform on every engine including the JVM-free RustCFML; it does not resolve symlinks (a symlink planted under `root` is still followed and trusted — see [#4020](https://github.com/wheels-dev/wheels/issues/4020)). ([#3912](https://github.com/wheels-dev/wheels/issues/3912)) diff --git a/changelog.d/localdisk-resolve-symlinks.added.md b/changelog.d/localdisk-resolve-symlinks.added.md deleted file mode 100644 index 2a4bf321ae..0000000000 --- a/changelog.d/localdisk-resolve-symlinks.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4020)) diff --git a/changelog.d/lucli-0-6-2-2-localhost.changed.md b/changelog.d/lucli-0-6-2-2-localhost.changed.md deleted file mode 100644 index 0ef2f2c205..0000000000 --- a/changelog.d/lucli-0-6-2-2-localhost.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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:/` diff --git a/changelog.d/lucli-0-6-2-3.changed.md b/changelog.d/lucli-0-6-2-3.changed.md deleted file mode 100644 index 0771ee02ed..0000000000 --- a/changelog.d/lucli-0-6-2-3.changed.md +++ /dev/null @@ -1 +0,0 @@ -- The Wheels CLI's runtime is LuCLI 0.6.2.3. `wheels --env=` (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 .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). diff --git a/changelog.d/mariadb-column-defaults.fixed.md b/changelog.d/mariadb-column-defaults.fixed.md deleted file mode 100644 index ba07af23a1..0000000000 --- a/changelog.d/mariadb-column-defaults.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/mcp-hide-app-creation.changed.md b/changelog.d/mcp-hide-app-creation.changed.md deleted file mode 100644 index ae2bf492fa..0000000000 --- a/changelog.d/mcp-hide-app-creation.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/mcp-refuse-generate-app.fixed.md b/changelog.d/mcp-refuse-generate-app.fixed.md deleted file mode 100644 index 1c8ed05a47..0000000000 --- a/changelog.d/mcp-refuse-generate-app.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 ` and `wheels generate --type=app ` 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) diff --git a/changelog.d/mcp-tool-polish.fixed.md b/changelog.d/mcp-tool-polish.fixed.md deleted file mode 100644 index ee82818abc..0000000000 --- a/changelog.d/mcp-tool-polish.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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) diff --git a/changelog.d/middleware-init-optional.fixed.md b/changelog.d/middleware-init-optional.fixed.md deleted file mode 100644 index 2e03fcd7c3..0000000000 --- a/changelog.d/middleware-init-optional.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/middleware-results-reach-controller.fixed.md b/changelog.d/middleware-results-reach-controller.fixed.md deleted file mode 100644 index 821983779a..0000000000 --- a/changelog.d/middleware-results-reach-controller.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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`. diff --git a/changelog.d/migration-null-alias.fixed.md b/changelog.d/migration-null-alias.fixed.md deleted file mode 100644 index 46a481830d..0000000000 --- a/changelog.d/migration-null-alias.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/migrator-crdb-serializable-step.fixed.md b/changelog.d/migrator-crdb-serializable-step.fixed.md deleted file mode 100644 index 1d448af929..0000000000 --- a/changelog.d/migrator-crdb-serializable-step.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/migrator-vendor-templates-defaults.fixed.md b/changelog.d/migrator-vendor-templates-defaults.fixed.md deleted file mode 100644 index a01142ee23..0000000000 --- a/changelog.d/migrator-vendor-templates-defaults.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/mock-stub-directory.fixed.md b/changelog.d/mock-stub-directory.fixed.md deleted file mode 100644 index 908571109b..0000000000 --- a/changelog.d/mock-stub-directory.fixed.md +++ /dev/null @@ -1 +0,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. diff --git a/changelog.d/model-generator-required-by-default.changed.md b/changelog.d/model-generator-required-by-default.changed.md deleted file mode 100644 index 9169f8750c..0000000000 --- a/changelog.d/model-generator-required-by-default.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/mysql-inline-string-literals.fixed.md b/changelog.d/mysql-inline-string-literals.fixed.md deleted file mode 100644 index 1ac6a48ccc..0000000000 --- a/changelog.d/mysql-inline-string-literals.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- On MySQL and MariaDB, a string value that Wheels writes into the SQL text instead of binding it (every value with `parameterize=false`, and literals a `where` string leaves in place, such as function arguments) is now written as a hex literal when it contains a backslash, so it reads the same whatever the `sql_mode`; other values are unchanged. A backslash in those positions is now an ordinary character, as it already was in bound values: if you wrote `\\` there to mean one backslash, write `\`. `LIKE ... ESCAPE '\\'` and `ESCAPE '\'` both still mean a single backslash. The query-builder guide now lists which `where` values are bound. diff --git a/changelog.d/mysql-signed-biginteger.changed.md b/changelog.d/mysql-signed-biginteger.changed.md deleted file mode 100644 index eaa94cf5ff..0000000000 --- a/changelog.d/mysql-signed-biginteger.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/nested-child-scope.fixed.md b/changelog.d/nested-child-scope.fixed.md deleted file mode 100644 index 5b0770f71e..0000000000 --- a/changelog.d/nested-child-scope.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Nested properties now only update or delete child rows that belong to the record being saved. A nested key that names a row of another record is treated as not found (it isn't changed, moved or deleted), the child's foreign key always comes from the parent's saved key, and posted primary-key values aren't kept on a new child except the join row's own foreign keys. This applies to `hasMany`, `hasOne` and nested `belongsTo` associations, including custom `joinKey` associations; keys are compared exactly (string keys such as `01` and `1` are different, and BIGINT keys are compared without rounding), and nested struct keys above 2^31 on BIGINT columns are looked up instead of being treated as new rows. A composite-key join model needs a `belongsTo` for each key column; without one, adding a row through nested properties now raises `Wheels.InvalidNestedKey` naming the column (it used to fail in the database with a NOT NULL error) diff --git a/changelog.d/nested-controller-extends-hint.fixed.md b/changelog.d/nested-controller-extends-hint.fixed.md deleted file mode 100644 index be25b23698..0000000000 --- a/changelog.d/nested-controller-extends-hint.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A nested controller (`app/controllers//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 diff --git a/changelog.d/new-app-commits-vendor.changed.md b/changelog.d/new-app-commits-vendor.changed.md deleted file mode 100644 index 56900b5e7f..0000000000 --- a/changelog.d/new-app-commits-vendor.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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/`. diff --git a/changelog.d/new-app-environment-settings.fixed.md b/changelog.d/new-app-environment-settings.fixed.md deleted file mode 100644 index b56dc1b5db..0000000000 --- a/changelog.d/new-app-environment-settings.fixed.md +++ /dev/null @@ -1,9 +0,0 @@ -- `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. diff --git a/changelog.d/new-app-routes-root-first.changed.md b/changelog.d/new-app-routes-root-first.changed.md deleted file mode 100644 index 35170cada8..0000000000 --- a/changelog.d/new-app-routes-root-first.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/new-avoid-pinned-ports.fixed.md b/changelog.d/new-avoid-pinned-ports.fixed.md deleted file mode 100644 index 8eba19eea7..0000000000 --- a/changelog.d/new-avoid-pinned-ports.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/new-shutdown-port-probe.fixed.md b/changelog.d/new-shutdown-port-probe.fixed.md deleted file mode 100644 index 881d081287..0000000000 --- a/changelog.d/new-shutdown-port-probe.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/new-writes-agent-configs.changed.md b/changelog.d/new-writes-agent-configs.changed.md deleted file mode 100644 index 243eb093f7..0000000000 --- a/changelog.d/new-writes-agent-configs.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/onerror-timeout-message.fixed.md b/changelog.d/onerror-timeout-message.fixed.md deleted file mode 100644 index 1bba7d884d..0000000000 --- a/changelog.d/onerror-timeout-message.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/3965)) diff --git a/changelog.d/oracle-beyond-64bit.fixed.md b/changelog.d/oracle-beyond-64bit.fixed.md deleted file mode 100644 index 90af7f1c36..0000000000 --- a/changelog.d/oracle-beyond-64bit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/oracle-number-precision.changed.md b/changelog.d/oracle-number-precision.changed.md deleted file mode 100644 index 466a709ed2..0000000000 --- a/changelog.d/oracle-number-precision.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/oracle-number38-bigint.fixed.md b/changelog.d/oracle-number38-bigint.fixed.md deleted file mode 100644 index c2b6d4a00a..0000000000 --- a/changelog.d/oracle-number38-bigint.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/order-direction-case.fixed.md b/changelog.d/order-direction-case.fixed.md deleted file mode 100644 index b7b8125b88..0000000000 --- a/changelog.d/order-direction-case.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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` diff --git a/changelog.d/packages-runtime-cache-offline.fixed.md b/changelog.d/packages-runtime-cache-offline.fixed.md deleted file mode 100644 index 21f85ebe6d..0000000000 --- a/changelog.d/packages-runtime-cache-offline.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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//` cache, and `--offline` serves expired cached data or says plainly that nothing is cached yet (#3911) diff --git a/changelog.d/packages-swap-recovery.fixed.md b/changelog.d/packages-swap-recovery.fixed.md deleted file mode 100644 index f168a0fce9..0000000000 --- a/changelog.d/packages-swap-recovery.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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//` 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//` is never half-written (#3902) diff --git a/changelog.d/pathguard-exact-containment.fixed.md b/changelog.d/pathguard-exact-containment.fixed.md deleted file mode 100644 index 17d3e0a225..0000000000 --- a/changelog.d/pathguard-exact-containment.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Path-containment guards that confine a resolved target to a root directory — database dump output, zip extraction, package manifest mappings, dev-UI asset and offline-docs serving, and image-tag source resolution — now compare canonical paths **exactly** (case-sensitively) with a separator-qualified boundary, through a shared `wheels.PathGuard` helper. Both the root and the candidate are canonicalised the same way, so a root configured in a different case than its on-disk spelling still resolves its own descendants. The always-on lexical checks and the existing authorization gates are unchanged. diff --git a/changelog.d/polymorphic-foreign-type.added.md b/changelog.d/polymorphic-foreign-type.added.md deleted file mode 100644 index 48fb8a5229..0000000000 --- a/changelog.d/polymorphic-foreign-type.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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 `type` diff --git a/changelog.d/polymorphic-schema-columns.fixed.md b/changelog.d/polymorphic-schema-columns.fixed.md deleted file mode 100644 index 877a1b7f8d..0000000000 --- a/changelog.d/polymorphic-schema-columns.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A polymorphic association without `foreignKey` / `foreignType` now finds its columns on the child's table like other associations do: `id` / `type` when they exist, otherwise `_id` / `_type`, which `t.references(polymorphic=true)` writes when `useUnderscoreReferenceColumns` is on (the `wheels new` default). Before, those apps threw `key [id] doesn't exist`. When both shapes exist, the names without the underscore win diff --git a/changelog.d/prependurl-forwarded-proto-trust.changed.md b/changelog.d/prependurl-forwarded-proto-trust.changed.md deleted file mode 100644 index 097bb4a756..0000000000 --- a/changelog.d/prependurl-forwarded-proto-trust.changed.md +++ /dev/null @@ -1 +0,0 @@ -- **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](https://github.com/wheels-dev/wheels/pull/3835)) is removed. ([#3836](https://github.com/wheels-dev/wheels/issues/3836)) diff --git a/changelog.d/processqueue-boxlang-postgres.fixed.md b/changelog.d/processqueue-boxlang-postgres.fixed.md deleted file mode 100644 index 3af56b8590..0000000000 --- a/changelog.d/processqueue-boxlang-postgres.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/pull/4487)) diff --git a/changelog.d/processrequest-state-restore.fixed.md b/changelog.d/processrequest-state-restore.fixed.md deleted file mode 100644 index b96a9f37be..0000000000 --- a/changelog.d/processrequest-state-restore.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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) diff --git a/changelog.d/prod-error-page-status.changed.md b/changelog.d/prod-error-page-status.changed.md deleted file mode 100644 index d4fa577585..0000000000 --- a/changelog.d/prod-error-page-status.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/project-test-runner-test-datasource.changed.md b/changelog.d/project-test-runner-test-datasource.changed.md deleted file mode 100644 index 0dd45858d7..0000000000 --- a/changelog.d/project-test-runner-test-datasource.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 `_test` requirement as the built-in runner. The run uses `_test`, or is refused unless `useTestDB=false` (`wheels test --no-test-db`) asks for the primary datasource. diff --git a/changelog.d/public-render-notfound.added.md b/changelog.d/public-render-notfound.added.md deleted file mode 100644 index a8144c9c4a..0000000000 --- a/changelog.d/public-render-notfound.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/3900)) diff --git a/changelog.d/query-assertions.added.md b/changelog.d/query-assertions.added.md deleted file mode 100644 index f5375ecba8..0000000000 --- a/changelog.d/query-assertions.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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 `?` diff --git a/changelog.d/query-builder-date-values.changed.md b/changelog.d/query-builder-date-values.changed.md deleted file mode 100644 index e486b73188..0000000000 --- a/changelog.d/query-builder-date-values.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/reload-header-rustcfml-cold-request.fixed.md b/changelog.d/reload-header-rustcfml-cold-request.fixed.md deleted file mode 100644 index deaecf3022..0000000000 --- a/changelog.d/reload-header-rustcfml-cold-request.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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.")` 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) diff --git a/changelog.d/reload-redirect-keeps-subfolder.fixed.md b/changelog.d/reload-redirect-keeps-subfolder.fixed.md deleted file mode 100644 index 51f0761f41..0000000000 --- a/changelog.d/reload-redirect-keeps-subfolder.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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). diff --git a/changelog.d/reload-redirect-leading-slashes.fixed.md b/changelog.d/reload-redirect-leading-slashes.fixed.md deleted file mode 100644 index c0ca642d9e..0000000000 --- a/changelog.d/reload-redirect-leading-slashes.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/route-binding-namespaced-controllers.fixed.md b/changelog.d/route-binding-namespaced-controllers.fixed.md deleted file mode 100644 index 94438e3bb7..0000000000 --- a/changelog.d/route-binding-namespaced-controllers.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/route-index.performance.md b/changelog.d/route-index.performance.md deleted file mode 100644 index 822d992907..0000000000 --- a/changelog.d/route-index.performance.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/rpm-rpmbuild-rewrap.fixed.md b/changelog.d/rpm-rpmbuild-rewrap.fixed.md deleted file mode 100644 index 750ce94dfa..0000000000 --- a/changelog.d/rpm-rpmbuild-rewrap.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/rustcfml-engine-cli.fixed.md b/changelog.d/rustcfml-engine-cli.fixed.md deleted file mode 100644 index c1ea59e16a..0000000000 --- a/changelog.d/rustcfml-engine-cli.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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=` and `wheels engines ` / `wheels engines rustcfml ` 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) diff --git a/changelog.d/rustcfml-engine-exclusivity-home.fixed.md b/changelog.d/rustcfml-engine-exclusivity-home.fixed.md deleted file mode 100644 index ab6faf42c3..0000000000 --- a/changelog.d/rustcfml-engine-exclusivity-home.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/saved-changes.added.md b/changelog.d/saved-changes.added.md deleted file mode 100644 index 3b2dce0b97..0000000000 --- a/changelog.d/saved-changes.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/schedule-runner-wiring.added.md b/changelog.d/schedule-runner-wiring.added.md deleted file mode 100644 index 92235c911c..0000000000 --- a/changelog.d/schedule-runner-wiring.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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`. diff --git a/changelog.d/scoped-key-lookups.fixed.md b/changelog.d/scoped-key-lookups.fixed.md deleted file mode 100644 index b3ea269a57..0000000000 --- a/changelog.d/scoped-key-lookups.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `findByKey()`, `exists(key=...)`, `updateByKey()` and `deleteByKey()` now combine the key with an incoming scope chain, query builder or `where` argument (AND) instead of replacing it, so a key outside the scope or `where` returns `false` or matches nothing. Lookups with no `where` are unchanged. `exists()` no longer throws in development when it gets both `key` and `where`: it checks the key within the `where`, the same in every environment. diff --git a/changelog.d/session-cookie-upgrade-note-range.fixed.md b/changelog.d/session-cookie-upgrade-note-range.fixed.md deleted file mode 100644 index bab0ede50b..0000000000 --- a/changelog.d/session-cookie-upgrade-note-range.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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](https://github.com/wheels-dev/wheels/issues/3967), [#3966](https://github.com/wheels-dev/wheels/issues/3966), [#3968](https://github.com/wheels-dev/wheels/issues/3968)) diff --git a/changelog.d/sqlite-biginteger-ddl.fixed.md b/changelog.d/sqlite-biginteger-ddl.fixed.md deleted file mode 100644 index 3d0fb866c4..0000000000 --- a/changelog.d/sqlite-biginteger-ddl.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- 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) diff --git a/changelog.d/sqlite-integer-bind-64bit.fixed.md b/changelog.d/sqlite-integer-bind-64bit.fixed.md deleted file mode 100644 index 3372b170b9..0000000000 --- a/changelog.d/sqlite-integer-bind-64bit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/sqlite-updateall-date.fixed.md b/changelog.d/sqlite-updateall-date.fixed.md deleted file mode 100644 index 773076a754..0000000000 --- a/changelog.d/sqlite-updateall-date.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/sse-resume-lasteventid.fixed.md b/changelog.d/sse-resume-lasteventid.fixed.md deleted file mode 100644 index 8039ec69b9..0000000000 --- a/changelog.d/sse-resume-lasteventid.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/start-port-in-use-hint.fixed.md b/changelog.d/start-port-in-use-hint.fixed.md deleted file mode 100644 index 3c4fa9a66a..0000000000 --- a/changelog.d/start-port-in-use-hint.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- `wheels start` stops before starting anything when the port in `lucee.json` is taken, names the Wheels server that holds it (with `cd && wheels stop`), and suggests a free `--port`. It used to warn and then fail with LuCLI's `Use: lucli server stop `. -- `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`. diff --git a/changelog.d/starter-app-application-cfc-sync.changed.md b/changelog.d/starter-app-application-cfc-sync.changed.md deleted file mode 100644 index 51412ed5ad..0000000000 --- a/changelog.d/starter-app-application-cfc-sync.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/starter-app-forgebox-version.fixed.md b/changelog.d/starter-app-forgebox-version.fixed.md deleted file mode 100644 index 76e56504fd..0000000000 --- a/changelog.d/starter-app-forgebox-version.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/starter-app-readme-migrate.fixed.md b/changelog.d/starter-app-readme-migrate.fixed.md deleted file mode 100644 index 056bf53354..0000000000 --- a/changelog.d/starter-app-readme-migrate.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/starter-app-string-defaults.fixed.md b/changelog.d/starter-app-string-defaults.fixed.md deleted file mode 100644 index 46a4ebff6c..0000000000 --- a/changelog.d/starter-app-string-defaults.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/starter-app-test-runner.fixed.md b/changelog.d/starter-app-test-runner.fixed.md deleted file mode 100644 index bc82c214e0..0000000000 --- a/changelog.d/starter-app-test-runner.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/starter-app-wheels-cli.changed.md b/changelog.d/starter-app-wheels-cli.changed.md deleted file mode 100644 index 8221b2fbf9..0000000000 --- a/changelog.d/starter-app-wheels-cli.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/stop-live-registration.fixed.md b/changelog.d/stop-live-registration.fixed.md deleted file mode 100644 index d2416d8090..0000000000 --- a/changelog.d/stop-live-registration.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels stop` now stops the project's running server even when it runs under an older registration name (after a `lucee.json` `name` 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) diff --git a/changelog.d/strict-arguments.added.md b/changelog.d/strict-arguments.added.md deleted file mode 100644 index 36bce460f2..0000000000 --- a/changelog.d/strict-arguments.added.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/subfolder-install-paths.fixed.md b/changelog.d/subfolder-install-paths.fixed.md deleted file mode 100644 index c95ab3f664..0000000000 --- a/changelog.d/subfolder-install-paths.fixed.md +++ /dev/null @@ -1,6 +0,0 @@ -- 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. diff --git a/changelog.d/template-reads-wheels-env.security.md b/changelog.d/template-reads-wheels-env.security.md deleted file mode 100644 index 0d57bc0a01..0000000000 --- a/changelog.d/template-reads-wheels-env.security.md +++ /dev/null @@ -1 +0,0 @@ -- Apps generated by `wheels deploy init` ran in development mode in production, exposing error details: the generated `config/environment.cfm` hardcoded `set(environment="development")`, so the image's `WHEELS_ENV=production` had no effect. The generated config now reads `WHEELS_ENV`: unset or empty means `development`, and any value other than `development`, `testing`, `maintenance` or `production` stops the app from starting instead of running with development-style error output. `config/settings.cfm` now reads `WHEELS_DATASOURCE` with the app name as its default. This applies to `wheels new` and the `wheels-base-template` download. `wheels info` and the `wheels test` preamble recognise the new `dataSourceName` form. **Existing apps:** templates don't reach an existing app, so replace `config/environment.cfm` with the generated version (see the Environments and Configuration guide), or hardcode `set(environment="production");` on production servers. A `WHEELS_ENV` line in `.env` wins over the process environment, so make sure a production server's `.env` doesn't say `development`. (#3946) diff --git a/changelog.d/tenant-debug-flags-nonoverridable.security.md b/changelog.d/tenant-debug-flags-nonoverridable.security.md deleted file mode 100644 index 3b6de94bb4..0000000000 --- a/changelog.d/tenant-debug-flags-nonoverridable.security.md +++ /dev/null @@ -1 +0,0 @@ -- A per-tenant `config` override can no longer switch on `showDebugInformation` or `enablePublicComponent` for a tenant's requests. `$get()`'s tenant-override guard already excluded `showErrorInformation`, but not the other two debug flags it is grouped with everywhere else, so a per-tenant config could expose debug output or the public-component surface for that tenant. All three debug flags are now non-overridable per-tenant. ([#3880](https://github.com/wheels-dev/wheels/issues/3880)) diff --git a/changelog.d/tenant-migrator-failed-step.fixed.md b/changelog.d/tenant-migrator-failed-step.fixed.md deleted file mode 100644 index c8b8ba159a..0000000000 --- a/changelog.d/tenant-migrator-failed-step.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/test-cfc-guides-version.fixed.md b/changelog.d/test-cfc-guides-version.fixed.md deleted file mode 100644 index 3573f0f0bf..0000000000 --- a/changelog.d/test-cfc-guides-version.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The `wheels.Test` (RocketUnit) deprecation message now links the `v4-1-0` testing guides instead of the stale `v4-0-0` path. ([#3968](https://github.com/wheels-dev/wheels/issues/3968)) diff --git a/changelog.d/test-run-live-scope-warning.changed.md b/changelog.d/test-run-live-scope-warning.changed.md deleted file mode 100644 index e4b9a75b5a..0000000000 --- a/changelog.d/test-run-live-scope-warning.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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 diff --git a/changelog.d/test-url-isolation.fixed.md b/changelog.d/test-url-isolation.fixed.md deleted file mode 100644 index 350885ae5d..0000000000 --- a/changelog.d/test-url-isolation.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/testclient-adobe-set-cookie.fixed.md b/changelog.d/testclient-adobe-set-cookie.fixed.md deleted file mode 100644 index 4425449a5b..0000000000 --- a/changelog.d/testclient-adobe-set-cookie.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `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 server set them; a value set with `withCookie()` is URL-encoded once. diff --git a/changelog.d/testclient-csrf.added.md b/changelog.d/testclient-csrf.added.md deleted file mode 100644 index aa98b9cf81..0000000000 --- a/changelog.d/testclient-csrf.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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()`. diff --git a/changelog.d/testclient-test-context.fixed.md b/changelog.d/testclient-test-context.fixed.md deleted file mode 100644 index a43b2eea41..0000000000 --- a/changelog.d/testclient-test-context.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- A `wheels.wheelstest.TestClient` created inside a test run and pointed at the test server now sends its requests to the test application, as `$testClient()` already did. Before, a client built with `new wheels.wheelstest.TestClient()` or an app subclass reached the live application and its primary datasource while the spec code used `_test`. - - The test server is a loopback host (`localhost`, `127.x.x.x`, `[::1]`) or the host of a configured `testClientBaseUrl`. - - A client aimed at any other host is unchanged, and so is one created outside a test run, such as in a scheduled task or a script. - - Pass `testContext=false` to address the live application. diff --git a/changelog.d/testcontext-path-trigger-anchor.fixed.md b/changelog.d/testcontext-path-trigger-anchor.fixed.md deleted file mode 100644 index 65645f1ea2..0000000000 --- a/changelog.d/testcontext-path-trigger-anchor.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The test-context path trigger binds the isolated test application only for a request whose path targets a runner endpoint (`/wheels/core/tests` or `/wheels/app/tests`), matched at the start of the path after stripping the query string and rejecting `..` / `//` segments (#4348) diff --git a/changelog.d/testcontext-selection-gate.fixed.md b/changelog.d/testcontext-selection-gate.fixed.md deleted file mode 100644 index 2e343f359c..0000000000 --- a/changelog.d/testcontext-selection-gate.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The test-runner application isolation (`_wheelsTest`, #3374) is now selected only in the `development` and `testing` environments. The runner path is matched against the request path. The `X-Wheels-Test-Context` header and `WHEELS_TEST_CONTEXT` cookie must carry the per-process runner secret and come from a loopback client. An isolated application refuses to start outside development and testing before any of its start-up code runs, and the core and app test runners refuse to run in production. **Existing apps:** test-context isolation needs `WHEELS_ENV` set to `development` or `testing` (the `wheels new` template sets it). Without it, specs run against the live application scope, and the runner logs an isolation-off warning and sends an `X-Wheels-Test-Isolation: off` response header. diff --git a/changelog.d/transaction-abort-rollback.fixed.md b/changelog.d/transaction-abort-rollback.fixed.md deleted file mode 100644 index e354a6f8f6..0000000000 --- a/changelog.d/transaction-abort-rollback.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A request that ends with `abort` inside a Wheels transaction (`invokeWithTransaction()`, a nested call that joins it, a savepoint unit, or `withAdvisoryLock(transaction = true)`) now rolls the transaction back on every engine, as it already did on Lucee and Adobe ColdFusion. On BoxLang the writes made before the abort were kept diff --git a/changelog.d/tx-advisory-locks.added.md b/changelog.d/tx-advisory-locks.added.md deleted file mode 100644 index 04d7364829..0000000000 --- a/changelog.d/tx-advisory-locks.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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](https://github.com/wheels-dev/wheels/issues/4198)) diff --git a/changelog.d/uniqueidentifier-column-types.fixed.md b/changelog.d/uniqueidentifier-column-types.fixed.md deleted file mode 100644 index b76cbdb26d..0000000000 --- a/changelog.d/uniqueidentifier-column-types.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `t.uniqueidentifier()` now creates a typed UUID column on every database: `UUID` on PostgreSQL, CockroachDB and H2, `CHAR(36)` on MySQL, SQLite and Oracle (SQL Server keeps `UNIQUEIDENTIFIER`). It previously emitted DDL with no type and SQL Server's `newid()` default, which failed everywhere else; `newid()` now becomes each database's own UUID generator. PostgreSQL `uuid` columns now bind as `cf_sql_other`, so finders on a `uuid` column, including ones created outside Wheels, work instead of failing with `operator does not exist: uuid = character varying`. Blank native UUID primary keys are now generated on PostgreSQL, CockroachDB and H2 too, and `generateUUID()` now always returns a version 4 UUID from a cryptographically strong source: on RustCFML it used to return a zero-filled value (#4094) diff --git a/changelog.d/upgrade-apply-backup-followups.fixed.md b/changelog.d/upgrade-apply-backup-followups.fixed.md deleted file mode 100644 index dbdcc6ac46..0000000000 --- a/changelog.d/upgrade-apply-backup-followups.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade apply` names old framework backups still inside `vendor/` (`vendor/wheels.bak-*` left by earlier versions), which make the framework log an error on every start, and says to move or delete them. When the backup can't be made (a separately mounted `vendor/`, for example), it says nothing was changed and gives the workaround: copy `vendor/wheels/` yourself, then use `--nobackup`. diff --git a/changelog.d/upgrade-apply-backup-outside-vendor.changed.md b/changelog.d/upgrade-apply-backup-outside-vendor.changed.md deleted file mode 100644 index 7cdabef2af..0000000000 --- a/changelog.d/upgrade-apply-backup-outside-vendor.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade apply` now parks the old framework at `.wheels/backups/wheels.bak-/` in the project instead of `vendor/wheels.bak-/`. 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. diff --git a/changelog.d/upgrade-apply-box-json-pin.fixed.md b/changelog.d/upgrade-apply-box-json-pin.fixed.md deleted file mode 100644 index d2ce7c5bbd..0000000000 --- a/changelog.d/upgrade-apply-box-json-pin.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade apply` now updates a CommandBox app's `box.json` `wheels-core` version (an exact version or a `^`/`~` range, in `dependencies` and `devDependencies`) to the framework it installed, and prints the change. Before, the old pin stayed, and a later `box install` copied the old framework back over the new `vendor/wheels/`, leaving a mix of both versions. Other specs (`4.x`, a channel, a `forgebox:` spec) are left alone with the exact line to change. If `box.json` isn't valid JSON, `apply` refuses before changing anything. diff --git a/changelog.d/upgrade-check-3x.fixed.md b/changelog.d/upgrade-check-3x.fixed.md deleted file mode 100644 index d74c47ce1c..0000000000 --- a/changelog.d/upgrade-check-3x.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade check` on 3.x-era apps: a 3.0 GA app (no version in `vendor/wheels/box.json`) now reports its real version instead of `unknown` and no longer sees findings twice; checks for 3.x code that 4.x removed (WireBox, `renderPage()`, old test bases) still run after the framework swap, while checks for 4.0 default changes and for things that are only deprecated (`plugins/`, which `wheels new` scaffolds, and `paginationLinks()`) are skipped for an app already on 4.x, so a stock 4.x app reports nothing breaking; `Missing csrfCookieEncryptionSecretKey` only fires for apps that set `csrfStore="cookie"`; and the RocketUnit base class (`wheels.Test`, which still runs on 4.x) is now an advisory instead of a breaking finding, so it no longer fails the CI gate, with fix text that says to convert the tests before switching to `wheels.WheelsTest` (the `wheels.Testbox` alias, also advisory, can switch directly) (#3939) diff --git a/changelog.d/upgrade-check-4x1-to-4x2.added.md b/changelog.d/upgrade-check-4x1-to-4x2.added.md deleted file mode 100644 index 15daa7e7b0..0000000000 --- a/changelog.d/upgrade-check-4x1-to-4x2.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/upgrade-check-4x2-advisories.added.md b/changelog.d/upgrade-check-4x2-advisories.added.md deleted file mode 100644 index 2c7f0b3b8f..0000000000 --- a/changelog.d/upgrade-check-4x2-advisories.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/upgrade-check-absent-hint.changed.md b/changelog.d/upgrade-check-absent-hint.changed.md deleted file mode 100644 index 6565792eb8..0000000000 --- a/changelog.d/upgrade-check-absent-hint.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `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)`. diff --git a/changelog.d/upgrade-check-application-cfc-fixes.added.md b/changelog.d/upgrade-check-application-cfc-fixes.added.md deleted file mode 100644 index ba2fe1bb3f..0000000000 --- a/changelog.d/upgrade-check-application-cfc-fixes.added.md +++ /dev/null @@ -1 +0,0 @@ -- `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. diff --git a/changelog.d/upgrade-check-newer-target.fixed.md b/changelog.d/upgrade-check-newer-target.fixed.md deleted file mode 100644 index d6ed031685..0000000000 --- a/changelog.d/upgrade-check-newer-target.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade check --to=` now says when the CLI running it is older than ``, because its checks and app template only know releases up to its own version. It recommends upgrading the CLI first, where it used to report "All Clear" for an incomplete upgrade. This is a warning only; the exit code doesn't change. JSON output gains `cliVersion`, `cliBehindTarget` and `warnings`. The app-template drift finding now points at the upgrade guides for the target version instead of naming a 4.0-era change (#4154). diff --git a/changelog.d/upgrade-check-plugin-dependencies.fixed.md b/changelog.d/upgrade-check-plugin-dependencies.fixed.md deleted file mode 100644 index e08bcfd4a5..0000000000 --- a/changelog.d/upgrade-check-plugin-dependencies.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade check`: the legacy plugins check no longer passes a 3.x app just because `plugins/` is empty or missing. It also reports plugin packages declared in `box.json` (installed under `plugins/`, or `cfwheels-*` packages) and code that reads `application.wheels.plugins`. diff --git a/changelog.d/upgrade-check-wirebox-scan-targets.fixed.md b/changelog.d/upgrade-check-wirebox-scan-targets.fixed.md deleted file mode 100644 index 4c0016176d..0000000000 --- a/changelog.d/upgrade-check-wirebox-scan-targets.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels upgrade check`: "Direct WireBox references" now scans `public/Application.cfc`, where 3.x apps bootstrap WireBox, and skips third-party packages under `app/` (a `box.json` install path, or any folder with its own `box.json`, such as `app/lib/logbox/`). It used to miss the app's own bootstrap and flag the package's code instead. diff --git a/changelog.d/urlrewrite-backslash-path.fixed.md b/changelog.d/urlrewrite-backslash-path.fixed.md deleted file mode 100644 index caa282e935..0000000000 --- a/changelog.d/urlrewrite-backslash-path.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Under CommandBox, a request whose path contains a backslash (`\` or `%5C`) now gets a 400 instead of a 500 from the servlet container. The fix is a new "Reject a backslash in the path" rule in the shipped `public/urlrewrite.xml` (and the `wheels new` template). Existing CommandBox apps can copy that rule from a new app's `public/urlrewrite.xml` into their own, ahead of the "Wheels pretty URLs" rule. `wheels start` (LuCLI on Tomcat) already answers 400 (#4114). diff --git a/changelog.d/urlrewrite-dead-format-rule.removed.md b/changelog.d/urlrewrite-dead-format-rule.removed.md deleted file mode 100644 index d15b0d2a23..0000000000 --- a/changelog.d/urlrewrite-dead-format-rule.removed.md +++ /dev/null @@ -1 +0,0 @@ -- 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) diff --git a/changelog.d/validation-condition-fail-closed-short.fixed.md b/changelog.d/validation-condition-fail-closed-short.fixed.md deleted file mode 100644 index 15dc62e0d0..0000000000 --- a/changelog.d/validation-condition-fail-closed-short.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Validation `condition` / `unless` now handles the expression shapes that used to evaluate silently. A bare name, on its own or on the left of a comparison (`condition="isActive"`, `condition="status != 'draft'"`), is resolved as `this.` (a property value or a method call) when the record has it. Before, a lone name evaluated to `false` and skipped the rule, and a left-hand name was compared as its own text, so the rule always ran. A name the record doesn't have, a `this.` reference on the right of a comparison, or an expression that isn't one value or a complete `left operator right` comparison now throws `Wheels.InvalidValidationCondition`. A lone `true` or `false` evaluates to its value, and a bare word on the right of a comparison stays a string literal. The validation guide now documents the supported forms (#3928) diff --git a/changelog.d/validation-this-path-fail-closed.fixed.md b/changelog.d/validation-this-path-fail-closed.fixed.md deleted file mode 100644 index 1214b2c796..0000000000 --- a/changelog.d/validation-this-path-fail-closed.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Validation `condition` / `unless` expressions that use a `this.` reference now fail closed for forms the evaluator never really supported, instead of silently skipping or mis-running the rule: a `this.` reference on the **right** of a comparison (e.g. `this.total gt this.limit`), and the word-form operators **`is`**, **`and`** and **`or`** on the `this.` path, now throw `Wheels.InvalidValidationCondition` naming the supported replacement. **Upgrade note:** replace `is` with `eq` (or `==`), `and` with `&&`, `or` with `||`, and put any `this.` reference on the left side of a comparison. ([#3964](https://github.com/wheels-dev/wheels/issues/3964)) diff --git a/changelog.d/verifies-failure-status.changed.md b/changelog.d/verifies-failure-status.changed.md deleted file mode 100644 index 4a9d20c9a7..0000000000 --- a/changelog.d/verifies-failure-status.changed.md +++ /dev/null @@ -1 +0,0 @@ -- 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. diff --git a/changelog.d/wheels-lookup.added.md b/changelog.d/wheels-lookup.added.md deleted file mode 100644 index 2e5249d84c..0000000000 --- a/changelog.d/wheels-lookup.added.md +++ /dev/null @@ -1 +0,0 @@ -- `wheels lookup ` 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. diff --git a/changelog.d/where-function-left-bound.fixed.md b/changelog.d/where-function-left-bound.fixed.md deleted file mode 100644 index 9f7a714f54..0000000000 --- a/changelog.d/where-function-left-bound.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A `where` condition that compares a function call with a value (e.g. `ABS(id) = 5`, `UPPER(title) = 'X'`, `UPPER(title) IS NULL`) now runs, with the value bound as a parameter, instead of failing with "Array index [1] out of range" diff --git a/changelog.d/where-function-value-include.fixed.md b/changelog.d/where-function-value-include.fixed.md deleted file mode 100644 index e111c11871..0000000000 --- a/changelog.d/where-function-value-include.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- With `include`, a `where` condition that compares a column with a function call (e.g. `id = ABS(1)`) now qualifies the column with its table instead of failing as an ambiguous column when the joined tables share its name diff --git a/changelog.d/where-literal-boundaries.fixed.md b/changelog.d/where-literal-boundaries.fixed.md deleted file mode 100644 index c8f831dd9e..0000000000 --- a/changelog.d/where-literal-boundaries.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A `where` string with a quoted pattern followed by `ESCAPE '...'`, or with lowercase or mixed-case `and` / `or` between quoted values, now runs instead of failing with an "invalid hexadecimal String" error diff --git a/changelog.d/where-literal-predicates.fixed.md b/changelog.d/where-literal-predicates.fixed.md deleted file mode 100644 index 8db4a86dea..0000000000 --- a/changelog.d/where-literal-predicates.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- `where=`: a literal compared with another table's column inside a subquery (`id IN (SELECT book_id FROM accesses WHERE user_id = 1)`) now throws `Wheels.UnbindableWhereValue`, naming the condition (bound values shown as `?`, quoted values hidden) and the alternatives: `whereIn()` with a value array, or running the subquery first. Before, it failed with `Array index [1] out of range`. `Wheels.ColumnNotFound` from a `where=` now explains the rule too. `where=` accepts model columns compared with values, so a condition with no column, such as `where="1 = 0"`, isn't supported: for no rows use `whereIn()` with an empty array, and for every row leave the condition out. diff --git a/changelog.d/where-odbc-date-literal.fixed.md b/changelog.d/where-odbc-date-literal.fixed.md deleted file mode 100644 index 2990993d29..0000000000 --- a/changelog.d/where-odbc-date-literal.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A CFML date interpolated into a finder's `where` string works again. Its ODBC escape form (`{ts '2000-01-01 00:00:00'}`, `{d '...'}`, `{t '...'}`) failed with `invalid hexadecimal String` when quoted (`where="createdAt >= '#since#'"`) or `the value for cfqueryparam cannot be determined` when bare; the date is now bound as its value (#3941) diff --git a/changelog.d/zero-row-bulk-op-rollback.fixed.md b/changelog.d/zero-row-bulk-op-rollback.fixed.md deleted file mode 100644 index e5a3de31df..0000000000 --- a/changelog.d/zero-row-bulk-op-rollback.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed a data-loss bug where a `deleteAll` or `updateAll` matching zero rows, run inside a raw `transaction {}` block, silently rolled back the entire enclosing transaction on Lucee and Adobe. A zero-row bulk operation is now correctly treated as success rather than a failed operation. Wheels-managed transactions (`transaction()` / `invokeWithTransaction`) were never affected. ([#3944](https://github.com/wheels-dev/wheels/issues/3944)) From 4eaf1f4f921ade4298938df850d7115bfa74cb1f Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sun, 11 Oct 2026 11:41:47 -0700 Subject: [PATCH 2/3] chore(release): add the Java 21+ package line and a reference to the 4.2.0 changelog #4237 and #4243 changed released Linux package behaviour without a fragment. The framework-suite datasource line (added by #4295 for #4254) gains its issue reference. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7712f41922..f32cf1c7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,7 +112,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo - 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/.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 `_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. +- When a run of the framework's own test suite (`/wheels/core/tests`) would use the app's primary datasource, it now needs `_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): `"
::"` 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) @@ -326,6 +326,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo - 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=` and `wheels engines ` / `wheels engines rustcfml ` 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) - `findByKey()`, `exists(key=...)`, `updateByKey()` and `deleteByKey()` now combine the key with an incoming scope chain, query builder or `where` argument (AND) instead of replacing it, so a key outside the scope or `where` returns `false` or matches nothing. Lookups with no `where` are unchanged. `exists()` no longer throws in development when it gets both `key` and `where`: it checks the key within the `where`, the same in every environment. From 315e05da0026249c4d268626a03ae044f7b21372 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sun, 11 Oct 2026 11:49:48 -0700 Subject: [PATCH 3/3] chore(release): describe job heartbeats as they ship in 4.2 Folds the heartbeat Added entry (#4477) and the grace Fixed entry (#4502) into one Added entry. The Fixed entry described a 'before' that never shipped, and the Added entry's 'heartbeats more often than its timeout' contradicted the grace rule. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32cf1c7fc..e0d225829d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo - `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. The stale-job reaper now measures from the latest heartbeat (`COALESCE(heartbeatAt, updatedAt)`), so a long job that heartbeats more often than its timeout is no longer reaped and run a second time. 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](https://github.com/wheels-dev/wheels/issues/4477)) +- 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](https://github.com/wheels-dev/wheels/issues/4477), [#4502](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/4479)) @@ -216,7 +216,6 @@ All historical references to "CFWheels" in this changelog have been preserved fo - `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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/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](https://github.com/wheels-dev/wheels/issues/4489)) -- `heartbeat()` now shortens how long a dead worker's job goes unnoticed. Before, a job was only reclaimed after twice its timeout whether it heartbeated or not, so heartbeats changed nothing. Now a job that has heartbeated is reclaimed once its heartbeats stop for its heartbeat grace, `set(jobsHeartbeatGraceSeconds = n)` (default 300; `0` turns it off; values below 30 are raised to 30) or the job's own `this.heartbeatGrace`, even inside its timeout. Jobs that never heartbeat behave exactly as before. Heartbeat at least every grace / 3. A stopped heartbeat isn't proof the worker died: a `perform()` stuck in a long call that can't heartbeat is reclaimed too. `heartbeat()` still never extends a job's timeout, which stays the hard cap on one attempt. An exclusive job's heartbeat now renews its lease by the grace window, so a job that stops heartbeating loses its lease on the same clock it's reclaimed on. ([#4502](https://github.com/wheels-dev/wheels/issues/4502)) - 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)