Repository navigation
chore: release 4.2.1 - #4668
Merged
Merged
chore: release 4.2.1#4668
Conversation
The extendedInfo of Wheels.Job.EnqueueFailed still told users to enqueue after the transaction commits whenever the enqueue ran inside a transaction on another datasource. Since #4282, a model save or invokeWithTransaction() on another datasource defers the write to the commit, so that advice now applies only to a raw transaction {} block. Reword it to match the background-jobs guide. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…RING_SPLIT parameter (#4321) * feat(model): run IN lists past the SQL Server parameter limit via STRING_SPLIT SQL Server binds at most 2100 parameters per statement and Wheels binds each IN list value as its own parameter, so a statement past the adapter limit of 2097 was refused with Wheels.TooManyParameters (#3906). On compatibility level 130+ (STRING_SPLIT), such a statement now binds its largest IN lists, until it fits, as one parameter each: IN (SELECT CAST(value AS <type>) FROM STRING_SPLIT(?, NCHAR(31))). Integer and decimal values are validated first and CAST (never TRY_CAST, which turns an empty value into 0), so NOT IN keeps its semantics; decimals cast to DECIMAL(38, s) exactly. String lists are compared as they are. Statements within the limit keep their SQL. Date and time lists, and databases below level 130, keep Wheels.TooManyParameters. The level is read once per datasource and kept in application.wheels, so a reload re-reads it. Fixes #4103 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): say which long IN lists SQL Server runs and which still fail Wheels.TooManyParameters on SQL Server now says that long integer, decimal, string and uniqueidentifier lists run as one STRING_SPLIT parameter on compatibility level 130+, and that date and time lists (#4318) or an older level still need batching. The query builder guide covers long IN lists on Oracle and SQL Server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): name every IN list SQL Server still refuses past the limit Only lists of integers, plain decimals, strings and uniqueidentifiers run as one STRING_SPLIT parameter. The advice, guide note and fragment now say so, and that any other list (dates, times, float, real, bit, text or binary values, or numbers such as +5, .5 or 1E5) still needs batching. A spec pins the message for a float list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf: memoise pluralize() and singularize() per application The inflection setup (irregulars, uncountables, the regex rule table) was rebuilt on every call. Results are now served from application.wheels.inflectionCache, keyed by direction and exact text, cleared by set(uncountables/irregulars), a settings fingerprint and reload, bypassed under a tenant override, and bounded to 2,000 entries of up to 100 characters. Refs #4150 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test: restore vertex/index/half to the inflection corpus; pin set() of an existing irregular #4186 fixed the BoxLang crash, so the three words go back into the equivalence corpus. A changed plural for an existing irregular isn't fingerprinted (it would double a cache hit), so a spec pins that changing it through set() takes effect, and the docblock and fragment say so. Refs #4150 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…4324) * feat(cli): wheels migrate unlock shows and clears the migration lock An instance that died holding the cross-process migration lock blocked every other instance until its lease ran out; the only way to clear it sooner was a hand-written DELETE against wheels_migrator_locks. Framework: migrationLockStatus() returns held, expired, owner, host, heldForSeconds and expiresInSeconds (a missing lock table means not held), and $migrationLockHolder() is built on it. releaseMigrationLock() removes an expired lease; with force=true it removes a live one, and the holder then fails its next renewal with Wheels.MigrationLockLost. The DELETE names the owner it read, so it never removes a lock another instance took in between. The bridge gains migrationLockStatus (GET) and migrationUnlock (POST, reload password). CLI: wheels migrate unlock prints who holds the lock. It exits 0 when nothing live holds it and refuses non-zero, naming --force, while a live instance does. --force (or --yes) removes it and prints what it removed. The migrate MCP tool gains the unlock action and a force flag. The lock-timeout error, the upgrade note and the CLI reference point at the command, keeping the DELETE as the fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(cli): migrate unlock reports a lock that changed hands, and only --force removes it Review fixes. releaseMigrationLock() compared the owner after the DELETE with the owner it read, so when another instance took an expired lease in between, the DELETE (keyed on the owner read) removed nothing yet released came back true and the CLI printed "Removed ... owner A". It now returns heldBy, the owner read back after the DELETE, and released = !Len(heldBy). The CLI fails non-zero naming the new holder when heldBy differs from the owner it set out to remove. --yes no longer forces unlock. LuCLI hands CLI flags and MCP arguments over the same way, so the alias let an MCP client that confirms forget/pretend out of habit remove a live lock without naming force. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#4203) * test(wheelstest): add shared $snapshotRoutes/$restoreRoutes/$clearRoutes helpers (#4194) Route specs that redefine the table must restore it in afterEach or they leak stale state into later specs. routes, staticRoutes, namedRoutePositions, the urlFor caches (application- AND request-scoped) and #4183's router index are separate structures, so a partial restore is the #4192 bug class (and formsSpec already restored only routes + namedRoutePositions, dropping staticRoutes). Add three helpers to wheels.WheelsTest: - $snapshotRoutes() -> deep-copy snapshot of every route-derived structure present (#4183 fields behind StructKeyExists, so no rebase needed); - $restoreRoutes(snapshot) -> exact restore, deleting managed keys that were absent at snapshot time; - $clearRoutes() -> empty the table + invalidate every route cache (shared, replacing the per-spec shims). PR1 of a staged migration: helpers + a round-trip self-test (SnapshotRoutesSpec) + migrate the view/* specs (links, urls, paginationHelpers, forms). dispatch/mapper/global/middleware/security/hardener follow in area PRs. Design: shared-docs state/dev1-design/evidence/4194-*. Updates the CLAUDE.md / consumer-ai "Test-specific gotchas" line (and the app template) that said $clearRoutes is not inherited -- it now is. Verified locally, view 605/0/0 + wheelstest 267/0/0 on lucee7, adobe2023 AND adobe2025. Refs #4194 Refs #4183 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(mapper): migrate mapper-area specs to the shared route helpers (#4194 PR2) Replace each spec's hand-rolled route save/restore with $snapshotRoutes() / $restoreRoutes() and drop the local $clearRoutes shims (now inherited from wheels.WheelsTest, added in #4202). Several of these restored only a subset -- mapperSpec, mapperModernSpec, mapper/MapperSpec, mapper/MapperHardenerShouldSpec, mapper/MapperNullPatternSpec and mapper/MapperRobustnessSpec each dropped namedRoutePositions and/or staticRoutes -- so the shared helper also closes those latent leaks. PR2 of the staged migration; stacked on #4202 (the helper is not on develop yet). Depends on #4202; merge order #4202 then this. Verified locally: full core suite clean on lucee7 (6518 pass; the only failures are the known TestClient port-mapped loopback artifacts, 0 real), adobe2023 (6532/0/0) and adobe2025 (6532/0/0); 13 mapper bundles present. Refs #4194 Signed-off-by: Peter Amiri <peter@alurium.com> * docs(wheelstest): address review nits on route-state helper docs - WheelsTest.cfc docblock: drop the stale 'once it merges' wording now that #4183 has landed; dynamicRouteIndex / routeTableGeneration are present. - .ai/testing.md snippet: define 'var g = application.wo;' so the beforeEach example compiles as written. - .ai/testing.md prose: name the dynamic route index and route-table generation in the capture list (the helper already captures both). - Re-sync the cli/lucli/templates/app copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… transaction (#4325) * fix(model): take SQL Server advisory locks for the session, outside a transaction On SQL Server, withAdvisoryLock() outside a transaction failed with "The statement or function must be executed in the context of a user transaction": sp_getapplock used its default Transaction owner. The docblock claimed full support and the adapter reported none. The lock is now owned by the database session (sp_getapplock @LockOwner = 'Session'), as on MySQL and PostgreSQL, and goes through the same verified release (#4197): - the acquire returns @@spid from the same batch; - a release runs only on the session that holds the lock (APPLOCK_MODE), since sp_releaseapplock errors on any other; - the held check reads the holder from sys.dm_tran_locks. Without the VIEW SERVER (PERFORMANCE) STATE permission it falls back to whether any session holds the lock. $supportsAdvisoryLocks() is true. The transaction path (#4198) shares the session acquire. Fixes #4220 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): recognise SQL Server permission errors by number, in any language The advisory-lock held check fell back only when the error message contained "permission": never on a server whose messages aren't in English, and never on Adobe, whose message is just "Error Executing Database Query.". It now keys on SQL Server's error number (300, 297, 229), read where each engine keeps it: nativeErrorCode on Lucee and Adobe, the driver exception's cause chain on BoxLang, errorCode for a thrown error. The message and detail text remain a fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r local listener (#4207) * fix(test): auto-detect TestClient/BrowserTest base URL from the server local listener When no base URL is explicitly configured, $testClient() and BrowserTest auto-detect it from the in-flight test-runner request, taking the port from the request's Host port. When the host port is mapped to a different internal port, a loopback request to that Host port does not reach this server. Add a precedence step, just below the explicit overrides and above the existing cgi detection, that takes the scheme and port from the server's local listener (the servlet request's getScheme() / getLocalPort()) and uses the loopback address (127.0.0.1) -- but only when the local port differs from the Host port (a mapping). Unmapped, the local port equals the Host port, so the step returns nothing and the existing cgi detection runs byte-for-byte unchanged; CI, normal `wheels start`, and vhost setups are unaffected. - Shared seam $detectBaseUrlFromServletPort(cgiScope, localPort, localScheme) on wheels.WheelsTest, with the guarded reader $servletLocalEndpoint(). - Wired into WheelsTest.$getTestBaseUrl and BrowserTest.$resolveBaseUrl (BrowserTest inherits the shared helpers). - Explicit overrides still win; #4148's host rules are untouched (host/scheme logic in the cgi path is unchanged). - Non-servlet engines (e.g. RustCFML) have no servlet request, so the step is skipped and resolution falls through to cgi detection. Specs: $detectBaseUrlFromServletPort covering the mapped case (http + https), the unmapped case (defers), and the no-local-port case, for both WheelsTest and BrowserTest. Verified under a port-mapped container on lucee7 (6545/0/0), adobe2023 (6540/0/0) and adobe2025 (6540/0/0) -- zero TestClient/BrowserTest loopback failures. Closes #4195 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(changelog): add fragment for the TestClient/BrowserTest base-URL fix (#4195) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): probe the loopback candidate before selecting it (AJP/TLS-proxy safe) Review follow-up (rev1-r1, rev1-r2). A local/Host port mismatch alone does NOT prove a usable HTTP loopback endpoint: - behind an AJP front end (IIS+BonCode, mod_jk) the local port is the AJP port, which speaks AJP, not HTTP; - getScheme() is the logical request scheme, not the listener transport (a TLS-terminating HTTP connector can report https on a plain-HTTP socket). So instead of trusting getLocalPort()+getScheme(), build loopback candidates (http first, then https) and probe them, selecting the first that answers as HTTP and otherwise falling back to the existing cgi detection. The probe: - a bare GET to /wheels/info (no test-context header, so no recursion), with redirects disabled and a ~1s timeout; - treats the request as answering only when the server actually sent a status line + headers (cfhttp `header` populated) -- a connect failure or read timeout (AJP, TLS mismatch) synthesizes a 408/502 status_code with no header, so status_code alone cannot be trusted; - is memoized per candidate (scheme+port) in the application scope, negative results included, so a fallback app pays the timeout at most once; - any error is swallowed as "no", so it never blocks a test run. Shared by both the WheelsTest ($getTestBaseUrl) and BrowserTest ($resolveBaseUrl) resolvers. Explicit overrides still win; #4148 host rules are untouched. Specs: candidate generation; selection with an injected probe (http-answers, https-only, nothing-answers/AJP fallback); a real dead-port probe; a socket that accepts the TCP handshake but never answers HTTP (the AJP read-timeout shape, capability-skipped where CreateObject("java") is unavailable), asserting the probe returns false within its timeout bound; and override precedence for both resolvers. Verified under a port-mapped container on lucee7 (6551/0/0), adobe2023 (6546/0/0) and adobe2025 (6546/0/0) -- 0 TestClient/BrowserTest loopback failures. Refs #4195 Signed-off-by: Peter Amiri <peter@alurium.com> * fix(test): probe a WEB-INF path (startup-independent) + TTL-cache negatives (#4195) Review follow-up (rev1-r1, rev1-r2, orch1). The previous probe GET /wheels/info hit the LIVE app, which in runner-only setups (the runner binds the isolated <name>_wheelsTest) is often cold: it triggered a full live onApplicationStart (autoMigrate etc.), blew the 1s timeout, cached a NEGATIVE for the app lifetime, and so a mapped container fell back to the broken cgi URL for the whole run -- reintroducing #4195 -- besides starting the live app as a side effect. Fixes: - Probe `<candidate>/WEB-INF/wheels-loopback-probe`. The servlet container refuses /WEB-INF with a 404 (status line + headers) BEFORE any URL rewrite or CFML dispatch, so the transport check is independent of app startup. Verified container-handled (404 in ~3ms, not a Wheels response) on Tomcat (Lucee/Adobe) and Undertow (BoxLang); still gated by the populated-`header` check so AJP and TLS mismatches read as no-answer. - Cache a POSITIVE result for the app lifetime but a NEGATIVE only for a short TTL (~60s), so a transient failure self-heals instead of permanently pinning a candidate as "no HTTP here", while a real AJP front end still avoids the timeout on every $testClient() within the window. Tests: - A new test double (LoopbackResolverStub) stubs the servlet local port and the probe to drive the COMPLETE resolvers -- WheelsTest.$getTestBaseUrl and BrowserTest.$resolveBaseUrl -- through mapped-success, cgi-fallback, and cache-once, not only the override path. - The socket-accepts-but-never-answers test (the AJP read-timeout shape) now reports skip() when CreateObject("java") is unavailable, instead of a silent pass. ($servletLocalPort is now public so the double can stub it.) Verified under a port-mapped container, full core suite 0 loopback failures on lucee7 (6556), boxlang (6556), adobe2023 (6551) and adobe2025 (6551). Refs #4195 Signed-off-by: Peter Amiri <peter@alurium.com> * test(wheelstest): isolate base-URL resolver specs from ambient overrides The complete-resolver probe tests drive BrowserTest.$resolveBaseUrl / WheelsTest.$getTestBaseUrl through the LoopbackResolverStub, but the stub only stubbed the servlet port and the HTTP probe — not the setting / system property / env-var override layers. The LuCLI CI job sets WHEELS_BROWSER_TEST_BASE_URL=http://localhost:30007, which the browser resolver returned before ever reaching the probe, so 'mapped + probe answers' expected http://127.0.0.1:9001 but got the env value. An env var cannot be unset from the JVM, so save/restore cannot isolate it. Extract the ambient (non-per-instance) override lookup into a public seam on each resolver ($configuredBrowserBaseUrl / $configuredTestClientBaseUrl) and override both in the stub to return "", so the stub-driven specs reach the probe/cgi path deterministically regardless of the CI environment. The production precedence (per-instance override > setting > sysprop > env > probe > cgi > default) is unchanged — the layers are only relocated behind the seam. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ute helpers (PR3) (#4204) * test(wheelstest): add shared $snapshotRoutes/$restoreRoutes/$clearRoutes helpers (#4194) Route specs that redefine the table must restore it in afterEach or they leak stale state into later specs. routes, staticRoutes, namedRoutePositions, the urlFor caches (application- AND request-scoped) and #4183's router index are separate structures, so a partial restore is the #4192 bug class (and formsSpec already restored only routes + namedRoutePositions, dropping staticRoutes). Add three helpers to wheels.WheelsTest: - $snapshotRoutes() -> deep-copy snapshot of every route-derived structure present (#4183 fields behind StructKeyExists, so no rebase needed); - $restoreRoutes(snapshot) -> exact restore, deleting managed keys that were absent at snapshot time; - $clearRoutes() -> empty the table + invalidate every route cache (shared, replacing the per-spec shims). PR1 of a staged migration: helpers + a round-trip self-test (SnapshotRoutesSpec) + migrate the view/* specs (links, urls, paginationHelpers, forms). dispatch/mapper/global/middleware/security/hardener follow in area PRs. Design: shared-docs state/dev1-design/evidence/4194-*. Updates the CLAUDE.md / consumer-ai "Test-specific gotchas" line (and the app template) that said $clearRoutes is not inherited -- it now is. Verified locally, view 605/0/0 + wheelstest 267/0/0 on lucee7, adobe2023 AND adobe2025. Refs #4194 Refs #4183 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(mapper): migrate mapper-area specs to the shared route helpers (#4194 PR2) Replace each spec's hand-rolled route save/restore with $snapshotRoutes() / $restoreRoutes() and drop the local $clearRoutes shims (now inherited from wheels.WheelsTest, added in #4202). Several of these restored only a subset -- mapperSpec, mapperModernSpec, mapper/MapperSpec, mapper/MapperHardenerShouldSpec, mapper/MapperNullPatternSpec and mapper/MapperRobustnessSpec each dropped namedRoutePositions and/or staticRoutes -- so the shared helper also closes those latent leaks. PR2 of the staged migration; stacked on #4202 (the helper is not on develop yet). Depends on #4202; merge order #4202 then this. Verified locally: full core suite clean on lucee7 (6518 pass; the only failures are the known TestClient port-mapped loopback artifacts, 0 real), adobe2023 (6532/0/0) and adobe2025 (6532/0/0); 13 mapper bundles present. Refs #4194 Signed-off-by: Peter Amiri <peter@alurium.com> * test(dispatch,global,security): migrate leak-class specs to shared route helpers (#4194 PR3) Migrate the remaining partial-clear / leak-class route specs onto $snapshotRoutes() / $restoreRoutes() and drop their local $clearRoutes shims: - dispatch/findMatchingRouteMegaSpec (partial-clear shim) - global/urlforSpec (partial-clear shim; keeps its URLRewriting + obfuscateUrls saves, which are not route-table state) - security/PaginationXssSpec (never saved staticRoutes -- the formsSpec leak class; its $clearRoutes CALL stays, now inherited) PR3 of the staged migration; stacked on #4203 (helper chain not on develop yet). Depends on #4203 (and #4202); merge order #4202, #4203, then this. Verified locally, full core suite clean on lucee7 (6518 pass; only the known TestClient port-mapped loopback artifacts, 0 real), adobe2023 (6532/0/0) and adobe2025 (6532/0/0). Refs #4194 Signed-off-by: Peter Amiri <peter@alurium.com> * test(global): keep urlForCache present before the route-state snapshot Migrating urlforSpec onto the shared $snapshotRoutes/$restoreRoutes helpers brought urlForCache into the restored key set. urlForCache is a lazily-created persistent app-scope memo (routing.cfm), so when it was absent at snapshot time $restoreRoutes deleted it on the afterEach — cascading so every test ended with it gone. The issue-2955 'memo lives in application scope so it survives across requests' test then found the memo absent (Expected [false] to be true). Ensure urlForCache exists before the snapshot in the outer beforeEach, so the full-state restore preserves the key instead of deleting it — matching the framework's treatment of it as a persistent memo. All assertions are unchanged; the issue-2955 block still manages the cache contents via its own save/restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…gh STRING_SPLIT (#4329) A statement whose date or timestamp IN lists push it past SQL Server's parameter limit threw Wheels.TooManyParameters. On compatibility level 130+, such a list now binds as one STRING_SPLIT parameter, each value cast to the type the driver binds a cf_sql_timestamp / cf_sql_date as (datetime2(7) / date), so it matches exactly the rows the same list matches bound one parameter per value. Casting to the column's own type would match more rows than that on DATETIME and SMALLDATETIME columns. The bind types are read once per datasource with SQL_VARIANT_PROPERTY and kept in application.wheels; a driver that binds other types keeps the error. Values must be yyyy-mm-dd with an optional HH:nn[:ss[.lll]], or CFML's {ts}/{d} form; time lists and other forms keep the error. Fixes #4318 Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…connection options (#4331) * fix(model): take MySQL and PostgreSQL advisory locks with the shared connection options The default (non-transaction) withAdvisoryLock() methods on MySQL and PostgreSQL built {datasource, username, password} by hand, even with an empty username and password, while model queries and the transactional path use $advisoryLockConnection(). They now use it too, like SQL Server. That helper resolves the datasource like $performQuery: a tenant (non-shared) model now takes its lock on the active tenant's datasource instead of the application's default one. Two tenants on different databases using the same lock name no longer exclude each other; a lock taken through a shared model stays on the default datasource. Fixes #4223 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): key the in-application advisory-lock wait on the lock's datasource withAdvisoryLock() first waits on an application-server lock named after the model's configured datasource and the lock name. With a tenant model's lock now taken on the tenant's datasource, two tenants using the same lock name on one server still queued behind each other there, and could time out. The wait is now keyed on the effective datasource: another tenant's datasource is another connection pool, which can't borrow the session holding the lock (#4197), so its callers needn't wait. The docs note that MySQL lock names are server-wide, so tenant databases on the same MySQL server still share a lock name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The SQL Server adapter reads two facts once per datasource and keeps them in application.wheels: the database's compatibility level (STRING_SPLIT, #4321) and the types the driver binds dates as (#4329). A failed read was kept too, so one transient error disabled STRING_SPLIT, or date-list splitting, until the next reload. A failed read is no longer kept. Its time is recorded, the answer is "not supported" until $probeRetrySeconds() (60) have passed, and then the probe runs again; a good read is kept as before. The back-off stops a broken probe from running before every long statement. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…callback aborts (#4330) * test(model): pin advisory-lock release when the callback ends the request (4219) A withAdvisoryLock() callback that ends the request from inside the lock — via a real abort or a real redirectTo() (cflocation) — must still release the database lock. The release runs in a finally, which executes on those exit paths (only a catch is skipped by abort), so there is nothing to leak; this makes that a permanent, measured guarantee instead of a caveat. Adds a core regression spec that drives request-ending actions over HTTP via $testClient and then, from its own separate connection, asserts the named lock is free — for the default (session-scoped) and transaction = true paths, each ending via abort and via redirectTo. A release regressed out of the finally would leave the lock on the aborting request's pooled connection and red this spec. Skips on databases without advisory-lock support; afterEach best-effort clears any probe lock so a failure cannot hand a held lock to a later spec. Fixtures: vendor/wheels/tests/_assets/controllers/AdvisoryLockProbe.cfc + the /_advisorylock routes. Verified locally on Lucee 7 with MySQL and SQL Server (4/0/0 each); the compat matrix proves every engine x MySQL/PostgreSQL/SQL Server. Closes #4219 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(model): add a positive control to the advisory-lock release spec Addresses review: the spec's 'not held afterwards' assertions passed vacuously if the probe request never reached withAdvisoryLock (a 404, the wrong app or datasource, an early throw) — tc.get()'s result was ignored. Each probe callback now records, from a SECOND connection while inside the lock and before ending the request, the result of a server-wide $isAdvisoryLockHeld into server["wheelsAdvisoryProbe_<name>"]. The spec asserts that marker is present and true (the lock was genuinely held at the moment of the abort / redirect), asserts the response (200 for abort, a 3xx redirect for redirectTo), and only then asserts the lock is free. If the request never took the lock the marker is absent and the test fails instead of passing silently. Verified it can fail: moving the release out of the withAdvisoryLock finally (into a catch, release after the block) reds the abort scenarios on MySQL and SQL Server (3 pass / 1 fail: 'a default-path lock stayed held after the callback aborted'), confirming the spec guards the finally. redirectTo stays green under that break because Wheels defers the cflocation to after the action, so the callback returns normally and the release runs — redirectTo is inherently safe; abort is the path the finally protects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): release withAdvisoryLock(transaction=true) lock when the callback aborts The release lived in the same try/finally that commits or rolls back the transaction. When an abort leaves the transaction, some engines skip that finally, so a session-scoped lock (MySQL, SQL Server) stayed held on the pooled connection until the connection closed. Move the release into an inner finally that carries no transaction-action statement, so it runs on every engine when the callback aborts; the normal path still commits before releasing so the next holder reads committed state. The regression spec now skips the transaction=true cases on PostgreSQL, whose pg_advisory_xact_lock is transaction-scoped and auto-released by the database, so there is no session-held lock to leak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(model): run the transaction=true abort case on PostgreSQL as a hang detector Address review on #4330. On PostgreSQL the transaction=true advisory lock is transaction-scoped and auto-released, so it cannot leak — but a request that never returns keeps it held. Un-skip the abort case there and bound it with a short $testClient timeout, so a stuck request fails that case rather than timing out the whole engine leg. The redirect case still skips on PostgreSQL. Add an optional timeout (seconds, default 30) to $testClient().get() for that bound; the default keeps existing behaviour. Reword the locking.cfm comment to state only that the release must sit in a finally with no transaction-action statement, which BoxLang skips on abort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…'s type (#4333) * fix(model): compare SQL Server date and time conditions in the column's type Every engine binds cf_sql_timestamp as DATETIME2(7), and SQL Server then converts a DATETIME or SMALLDATETIME column exactly, so a value with a fraction of a second never equalled what the column stored in 1/300-second (or one-minute) steps. timestamps() creates DATETIME columns on SQL Server, so createdAt = <a saved value> could match nothing. cf_sql_time bound as DATETIME on Lucee (an error against a TIME column), not at all on BoxLang, and without its fraction on Adobe. A condition param now carries its operator, and the SQL Server adapter casts: - =, <>, IN and NOT IN on a DATETIME / SMALLDATETIME column to that type, so the value rounds the way the stored value did. Ranges keep the exact comparison, and DATETIME2 / DATE columns are unchanged; - any comparison on a TIME column, bound as text, to TIME(7). An IN list becomes one CAST per value; the parameter count is unchanged. $temporalComparisonType() names the type for each column. Fixes #4326 Fixes #4327 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): split long SQL Server date and time lists in the column's type A long IN list on a DATETIME or SMALLDATETIME column now splits with the column's own CAST, as the same list does when it fits, so the two paths match the same rows (sqlServerDateStringSplitSpec). A long TIME list splits too, cast to TIME(7) like a time condition; it used to keep Wheels.TooManyParameters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): say that <> and NOT IN on a SQL Server DATETIME can leave out rows Comparing at the column's precision lets = and IN match rows they never matched, and makes <> and NOT IN leave out a row whose stored value the rounded value equals: createdAt <> '...10:00:00.001' no longer returns a row stored as 10:00:00.000. The fragment and upgrade guide now say so, and a spec pins the <> / NOT IN case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(model): keep a date's milliseconds in SQL Server TIME text on RustCFML RustCFML treats a date object as a simple value whose text drops the milliseconds, so $timeText() read it as a date-time string and lost them. A date-time string is now cut to its time only when it carries a fraction; any other date goes through TimeFormat() and Millisecond(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… abort inside it (#4335) * fix(model): roll back a Wheels transaction when the request ends with abort inside it The rollback for a failed transaction method lived in the finally/catch of a try that also has a catch clause. On BoxLang, abort skips a finally whose try has a catch clause, so a request that ended with abort inside invokeWithTransaction() (or a nested call, a savepoint unit, or withAdvisoryLock(transaction = true)) closed the transaction with the writes made before the abort. Lucee and Adobe ColdFusion rolled back. $transactionBody() now runs the method through $invokeTransactionMethod(): a catch-free try/finally around an inner try/catch that records a normal throw. When the method neither returned nor threw, the outer finally runs transaction action="rollback". Normal completion is unchanged. CLAUDE.md gains cross-engine invariant 22 for the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): name the catch clause as what makes BoxLang skip a finally on abort The release comments in $advisoryLockTransactionBody() said a transaction-action statement in a finally makes BoxLang skip it on abort. Measured: the trigger is a catch clause on the try; transaction actions make no difference. The comments now say so and point at cross-engine invariant 22, and note that invokeWithTransaction() performs the abort rollback on BoxLang. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… the pattern (#4337) * fix(core): run Seeder and LocalDisk cleanup in a catch-free finally; guard the pattern BoxLang skips a finally whose try has a catch clause when the request ends with an abort, so cleanup in a try/catch/finally wrapped around app code can be lost (see the BoxLang finally/catch rule in CLAUDE.md Cross-Engine Invariants). Seeder.generateSeeds() (restores request.$wheelsTransactionWrapper) and LocalDisk.$assertSymlinkResolutionAvailable() (deletes the probe temp dir) both used a single try/catch/finally. Nest each so the cleanup lives in an outer catch-free try/finally that runs on every engine on abort, keeping the existing catch on the inner try. Add TryCatchFinallyGuardSpec: a structural scan that fails if any production vendor/wheels source (*.cfc / *.cfm, excluding /tests/ and /wheelstest/) has a single try with BOTH a catch and a finally. New code that needs both must nest. withAdvisoryLock's transaction-path outer try is allowlisted, because its lock release already runs in an inner catch-free finally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(security): make the try/catch/finally guard a whole-file brace matcher with a self-test The line-based frame scan had blind spots (rev1-r3): it missed Allman-style own-line catch/finally, a same-line catch body followed by a next-line finally, and a one-line try/catch/finally, and it false-positived a one-line inner try/catch nested inside a safe outer try/finally. None occur in the tree today, but a future edit could hit them. Replace it with a whole-file brace matcher: blank comments and string literals to spaces in a single char pass (preserving length and newlines, no global regex — Lucee 7 hang), then for each `try {` brace-match its body and walk the following catch/finally chain. A try is an offender iff its own chain has BOTH a catch and a finally, so a nested outer-finally / inner-catch is correctly clean. Add a self-test that runs the scanner over the four wrong shapes plus the catch-free, nested-one-line, nested-multiline and try/catch-only clean shapes. Document that the scan covers script syntax only (the cf-tag try form is not scanned; the angle-bracket syntax is kept out of comments because Lucee's tag scanner parses a literal cf-tag even inside one). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…onal advisory-lock body (#4338) * refactor(model): drop the abort rollback from the transactional advisory-lock body $advisoryLockTransactionBody() rolled the transaction back in its outer finally when the callback neither committed nor threw, i.e. when the request ended with abort. Since #4335, invokeWithTransaction() itself rolls back on abort ($invokeTransactionMethod()), and this method only runs through invokeWithTransaction(), so that rollback is redundant. Removing it also drops the two flags only it read and the outer catch that only set one of them, which leaves the outer try catch-free (cross-engine invariant 22). The inner catch-free release on abort or throw and the release after a failed commit are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(core): drop the locking.cfm exception from the try/catch/finally guard $advisoryLockTransactionBody() no longer has a try with both a catch and a finally, so the guard needs no exceptions. With develop's locking.cfm and an empty allowlist the guard fails at the old outer try; with this branch it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(web): bring the wheels.dev home page up to date with 4.x Point the guides and API links at the current 4.1 docs instead of 3.0, use the 4.x command names (wheels migrate, wheels start), and state the supported engines the 4.x docs list (Lucee 6/7, Adobe ColdFusion 2023/2025, BoxLang, RustCFML) in both places. Reword version-specific counts so they don't go stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(web): refresh visual baseline(s) (landing) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml targeting branch peter/landing-home-fixes. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering. * chore: trigger CI after the baseline refresh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#4342) * feat(cli): wheels lookup searches the API reference and guides offline An AI assistant or a person at the terminal had no way to look up a framework function's parameters or a guide topic without a browser: the offline HTML bundle (wheels docs fetch) is a ~35 MB download meant for reading, and the stdio MCP server had no docs tool. wheels lookup <query> (and the new lookup MCP tool) reads a docs index shipped inside the CLI module. A function name (findAll, model.findAll) prints its full entry: signature, parameters, example, link. A phrase lists the best-matching guide sections and functions with a snippet, a link and an id that prints the whole entry. --kind, --limit and --format=json narrow and shape it. No network, server or project. tools/build/scripts/build-docs-index.mjs builds the index from the version's API JSON and guides tree. The release and snapshot builds run it with --strict before the module tarball, so the index always describes the CLI's own framework version (a snapshot uses the API JSON its fast-test job generates). The index isn't committed; from a source checkout, lookup names the build command. The builder has node tests, and web-scripts-tests builds the index from every PR that touches the guides or the API JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * ci: build the wheels lookup index before publishing, and keep manual develop runs working Review fixes. The index step ran after "Publish All Packages to ForgeBox", so an index failure would leave a version on ForgeBox with no GitHub release, module or package-manager update behind it, the half-published state #4314 fixed. It now runs right after the release checklist, before any prepare or publish step, with its own Node setup. A manual workflow_dispatch on develop has no fast-test artifact, so --strict always failed it. The build is strict on main and on develop when the api-docs-snapshot artifact downloaded; otherwise it builds from the newest committed API JSON and emits a ::warning:: saying so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): self-host the Inter and JetBrains Mono brand fonts tokens.css names Inter and JetBrains Mono, but nothing loaded them, so every site rendered in system fonts. Ship both as latin-subset variable woff2 files in @wheels-dev/ui with font-display: swap, imported from tokens.css so every site that uses the tokens gets them. No third-party font request. OFL license texts sit next to the files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(web): refresh visual baseline(s) (all) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml targeting branch peter/web-self-host-fonts. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering. * chore: trigger CI after the baseline refresh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…s own message (#4345) wheels migrate unlock refuses while a live instance holds the lock. It printed the holder (host, owner, how long, when the lease expires) and the --force hint, then threw "held by another instance; nothing was removed". LuCLI's MCP handler returns only the thrown message and drops everything printed before it, so an MCP client saw neither the holder nor the next command. The thrown message now carries both; on the command line it reads the same. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ase (#4346) $orderByClause() checked for " ASC" / " DESC" with case-sensitive Find(), so a lowercase direction was missed and " ASC" was appended: order="id desc" sorted ascending, and a "table.column desc" item became "table.column desc ASC" and threw Wheels.InvalidOrderClause. The query builder's orderBy("id", "desc") reached the same code. Each order item now goes through $normalizeOrderItem(): whitespace runs (spaces, tabs) collapse to one space, a trailing asc/desc is read in any case and emitted in upper case, and an item without one sorts ascending. The query builder stores the direction in upper case too. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d font preloads (#4347) * fix(web): keep the packages grid inside the page; add italic Inter and font preloads The packages index's third card column ran past the viewport: plain 1fr grid tracks and the card's flex command box couldn't shrink below the longest install command. Use minmax(0, 1fr) tracks and min-width: 0 on the command box, which already scrolls. Add the latin italic Inter face, so italics are no longer synthesized, and preload the two main woff2 files on every site (a FontPreload component in the layouts and a Starlight Head override for guides and api). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(web): refresh visual baseline(s) (packages-index) Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml targeting branch peter/web-fonts-followup. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering. * docs(web): note the Starlight version the Head override mirrors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…4344) * feat(cli): wheels new writes .mcp.json and .opencode.json by default A new app's AI assistant couldn't see the Wheels MCP server until the user also ran wheels setup agents, which the scaffold output never mentioned. wheels new (and wheels create app) now writes the same two files in the new app's root and lists them in its summary; --no-agents skips them. A failure to write them only warns, with the setup agents command to run later: the app works without them. setup agents' writer moves into $writeAgentConfigs(root, force), which returns what it wrote without printing, so both commands share it and setup agents' output is unchanged. The mcp-integration guide said there was no generated file and that OpenCode used the mcpServers shape; it now describes wheels new, setup agents and OpenCode's own shape. The wheels new reference lists --no-agents and the two files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * test(cli): give NewAppAgentConfigsSpec's scaffolds an explicit port Review fix. The spec's four real wheels new calls passed no port, so each scaffold's free-port selection probed the default 8080, and CI's fallback-port sentinel failed the CLI job on those four connections. They now pass an OS-assigned free port, as NewPinnedPortsSpec does, and the mock's $defaultNewPort throws, so a probe of the default port fails the spec itself rather than only the sentinel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Replace the version list (it named v3.1.0, which was never tagged) with the newest release of each line: v4.1.2, v4.0.6, v3.0.0+50 and v2.5.1. Index docs/consumer-ai, the AI reference shipped in apps, and drop two excluded folders that no longer exist. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…guide (#4353) The IDE-paths note described OpenCode's shape without "enabled": true, which wheels new and wheels setup agents also write, and still ended with "the server definition itself is identical across clients", which the paragraph before it contradicts. It now shows the whole entry and says what is the same for every client: the wheels mcp wheels command. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(model): report what the last save wrote with savedChanges() hasChanged(), changedFrom(), changedProperties() and allChanges() describe unsaved changes, so a successful save resets them, and an afterCommit had no way to tell what its commit changed. Apps kept their own flags across a transaction and got their lifecycle wrong. savedChanges(), hasSavedChange(), savedChangeFrom() and savedChangedProperties() are their saved counterparts: - set right after the row write, before the after* callbacks, so afterSave, afterCommit and the caller see the same thing; a failed save puts back the previous state, a save that changed nothing reports nothing; - each transaction queue entry keeps its save's snapshot, so each afterCommit sees the save that queued it, and an afterRollback the save that was undone, after which the state from before it is restored (also for a model with no transaction callbacks); - on a create, every property the insert wrote, from ""; - reload() and clearChangeInformation() clear it; save(reload=true) keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(model): document a save inside a callback in savedChanges() A save inside one of the object's own callbacks replaces savedChanges() with its own changes, which include the outer save's because the outer save hasn't reset its baseline yet. The guide says so with an example, and a spec pins it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…own for guides and API (#4356) Serve plain-text docs for language models from the guides and API sites: an llms.txt index, llms-full.txt (every current-version page), llms-small.txt (an outline of the current version), one set per docs version under /llms-txt/, and every page as Markdown at <page URL>.md. The current version comes from versions.ts. Built by plain Astro endpoints over the content collection, with no new dependencies. Add a wheels.dev/llms.txt index, and a CI check that fails the deploy when a file is missing, a link doesn't resolve, a page lacks its .md, or a non-current version leaks into llms-full / llms-small. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ir on restart (#4636) * ci: cache Lucee Express and Maven downloads, stop wiping the server dir CI pulls hundreds of GB per week from cdn.lucee.org because every job re-downloads Lucee Express and every post-JDBC restart wipes ~/.lucli/servers (deleting the installed H2 extension and re-fetching it from Maven). Address issue #4463: - Cache ~/.lucli/express with actions/cache in pr.yml (fast-test and tutorial-e2e), snapshot.yml fast-test, and the setup-wheels-test-env composite used by smoke-env and the bot workflows. Keyed on the LuCLI template and Lucee CI config, with an OS restore-key; the cache-hit value is logged. - Point Lucee's Maven repository at a path outside the server dir (~/.lucli's sibling .lucee-mvn) and cache it, so extension downloads survive independent of server state. The Docker/CommandBox matrix legs are deferred: their Lucee lives inside the container image, so a host Maven cache cannot be mounted without changing matrix behaviour. - Stop starting the restarted server with --force. The flow is now: first start (Express restore/download) -> stop -> install the SQLite JDBC driver into the Express lib/ext -> start again without --force, so ~/.lucli/servers and its extensions survive. A post-restart find logs whether the H2 extension is still present, and a failed restart now fails the step with the server log. The H2 extension version stays at 1.3.172; bumping it is a separate follow-up. Signed-off-by: Peter Amiri <peter@alurium.com> * ci: keep the Lucee Maven cache under runner.temp actions/cache rejects cache paths containing "..", and the runner context is not available in job-level env. Set LUCEE_MAVEN_LOCAL_REPOSITORY on the server-starting steps and cache ${{ runner.temp }}/.lucee-mvn instead, so the Maven repository is a valid absolute cache path. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…d date form helpers (#4633) Carries the community fix from @michaeldiederich (PR #4633), with maintainer commits: refactor for the complexity gate, changelog scoping, and a conventional title. Null values in data passed to renderWith() no longer NPE in the Oracle TIMESTAMP conversion, and the date/time form helpers no longer call isInstanceOf() on null or non-object values. JSON rendering and the form-helper path are fixed; XML rendering of null struct values goes through a separate toXML path and remains a follow-up. Signed-off-by: Peter Amiri <peter@alurium.com>
Adds the Groundmail SMTP row to the v4.2 sending-email guide, with review edits: Groundmail in the prose provider list, a vendor-contribution disclosure, and the sandbox note rewritten around the concrete 403 behaviour. Also carries a web-deploy CI fix so fork PRs skip the Cloudflare publish step (the Build and llms checks still run) — fork runs cannot read CLOUDFLARE_API_TOKEN, which red-X'd every Deploy job on web/** changes. Contribution by @Ktryberceo. Signed-off-by: Peter Amiri <peter@alurium.com>
…up by (#4637) #4634: with include=, the duplicate-prefix pass split the select clause with ListToArray, so an expression such as CONCAT(IF(ISNULL(titel.ausgabe)=1, '', CONCAT(titel.ausgabe, ' ')), kontakt.nachname) was split into fragments, the tail was treated as a duplicate property and dropped, and the generated SQL was invalid. Remove the second, duplicated inline copy of that pass in $createSQLFieldList and split both it and the distinct group-by item list with $splitOutsideFunctions(), which keeps function arguments together. SQL without comma-bearing functions is unchanged. Regression specs cover the reported expression shape, a plain select item before it, several comma-bearing functions side by side, and a distinct group-by that must not receive split subquery fragments. Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: bpamiri <180555+bpamiri@users.noreply.github.com>
…4647) Extra query columns (custom select aliases, joined columns, driver metadata) were appended using the raw query columnList casing, so on Lucee JSON output carried e.g. SGP_ID while property-backed keys used the canonical property casing. Extra-column keys now follow resetPropertiesStructKeyCase: lowercased when enabled (the default), raw as before when disabled. The property loop and the joinAlias path already use canonical property names; the setting is read once per row, not once per column. No public API change, but camel-cased select aliases now serialize lowercased by default, which the changelog calls out. Signed-off-by: Peter Amiri <peter@alurium.com>
… useTestDB (#4648) #4643: $testDataSourceDecision() returned "swap" as soon as <primary>_test was registered, before consulting allowTestsAgainstPrimaryDatasource, so the documented 4.2 behavior was unreachable whenever a test datasource existed. For an omitted useTestDB the opt-out is checked first and runs on the primary datasource with a warning; explicit useTestDB=true still requires the test datasource and explicit useTestDB=false still targets the primary. The framework's own suite passes ignoreAllowPrimary, so it keeps using <primary>_test and still refuses when it is missing. #4642: the DebugBar Test Suite panel's "Open full runner" link and in-page JSON runner URL now include useTestDB=false when the app allows tests against the primary datasource, so the same decision applies there; otherwise both URLs are unchanged. Regression specs cover the omitted/true/false x allowPrimary x candidate combinations and render the debug bar to assert both URLs. Signed-off-by: Peter Amiri <peter@alurium.com>
#4651) #4629 item 2: the association JOIN memo key only carried soft-delete and alias state, so two H2 tenants with different DATABASE_TO_UPPER/DATABASE_TO_LOWER folding shared one memo entry and the second reused the first tenant's quoted JOIN text. Append the adapter's quoting mode to the key. Add $identifierCase() to databaseAdapters/Base (default "", meaning quoting is datasource-independent) so the shared SQL builder never calls an H2-only method; H2 already overrides it. #4629 item 3: extract H2 $classifyIdentifierCase(settings) from $readIdentifierCase and cover every verified settings shape with a table-driven spec, including the H2 1.3.x case where DATABASE_TO_LOWER is absent and quoted identifiers are case-insensitive. Item 1 (migrator DDL quoting) and item 4 (fixture DELETE hygiene) are separate follow-ups per the brief and are not changed here. Signed-off-by: Peter Amiri <peter@alurium.com>
…acement (#4646) (#4649) * fix(engineAdapters): upper-case by walking characters, not regex replacement Adobe CF 2023 update 26 / 2025 update 14 changed regex REPLACEMENT parsing: the backslash-u case escape now emits a literal "u", so the replacement-based casing in Wheels produced a controller named "main" -> "umain", corrupted hyphenated controller/action names, and prefixed /wheels/info setting labels with "u". Replace the four affected sites with a manual character walk: - add $upperCaseAfterDash() to engineAdapters/Base.cfc (public, inherited by every adapter) and use it in controllerNameToUpperCamelCase(); - use it in the BoxLang adapter override; - use it for route action camel-casing in Dispatch.$ensureControllerAndAction(); - build the outputSetting() label with a manual loop in public/helpers.cfm. Output is identical to the previous replacement on pre-patch engines, including leading/repeated/trailing dashes, digits, uppercase and non-ASCII characters after a dash. Regression specs pin the patch-26 shapes, render the info labels, and guard the four files against the removed escape. The follow-up \l/\U replacements (hyphenize, flash keys, named-route derivation) stay for separate PRs per #4646. Signed-off-by: Peter Amiri <peter@alurium.com> * test(engineAdapters): harden the #4646 casing specs Use toBeWithCase() for every string expectation so case regressions cannot pass silently, correct the non-ASCII expectation (café-über -> Café-über), add the guarded differential against the legacy regex on engines where it still parses, and cover the base and BoxLang adapters directly, including ".user"/"..user". Signed-off-by: Peter Amiri <peter@alurium.com> * test(engineAdapters): make the #4646 specs engine-aware Adobe's compiler rejects the BoxLang adapter's internal `arguments.object` argument, so instantiate that module only on BoxLang and assert the base adapter's dot handling elsewhere. On BoxLang the live adapter legitimately works on the whole dotted name, so assert its own expected output. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…alues (#4638) (#4650) * fix(controller): render null XML values instead of throwing Follow-up to #4633 (#4638). renderWith()'s JSON branch is null-safe, but the XML branch still passed null struct/array/query/component values into toXML, whose required data argument rejects null and whose branches read it directly. Guard each recursion with isNull() and emit a type="null" element; a null top-level value renders as empty output in $toXml(). The date-select helpers also coerce a null bound value to "" before rendering, so no null reaches the select renderers. Regression specs cover renderWith() JSON and XML with null struct values (JSON already passed on this head; XML failed before this change) and dateSelect() with a null bound property. Add a changelog fragment. Signed-off-by: Peter Amiri <peter@alurium.com> * test(controller): assert the null XML element without formatting BoxLang indents the generated XML differently, so match type="null" with a format-agnostic FindNoCase instead of a contiguous element string. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(controller): return a null XML document for a root-level null $toXml() is declared xml, so returning "" for a null root value throws "Cannot cast String [] to a value of type [xml]" on null-capable engines. Return a well-formed null document instead (mirroring the nested toXML element) and keep the changelog wording accurate. Cover it with a root-null spec that skips where a null cannot be passed to a required argument (Lucee null support off, RustCFML), plus a source guard that always checks the declared document shape. Strengthen the formsdate spec so a null bound property must render exactly like an empty value and produce the full 12-option month select. Signed-off-by: Peter Amiri <peter@alurium.com> * test(global): probe null support instead of catching $toXml failures The root-null spec wrapped $toXml in try/catch and skipped on any exception, so a regression on a null-capable engine (the xml-cast error) would be skipped rather than fail. Probe null-argument support with an unrelated helper, skip only when the engine cannot pass null, then call $toXml outside any try/catch and assert toBeXML() + the null element. Keep the static source guard as the cross-engine pin. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…4646) (#4652) * fix: remove \l/\U replacement case flags from names and flash keys Adobe CF 2023 update 26 / 2025 update 14 parse the \l and \U replacement case flags as literal letters, so hyphenated route names became "wheels-l-lis-la-l-lframework", flash keys became "lSuccess", and named route derivation produced "adminUposts". #4649 fixed the \u sites; this covers the remaining \l/\U sites. - hyphenize(): drop the redundant \l flags - the final LCase already lower-cases the inserted letters and the hyphen insertion means the second pass never depends on the first pass's case. - redirection.cfc: lower-case the first character after the flash prefix by hand (Len > 5 mirrors the old required sixth character; CompareNoCase mirrors the case-insensitive prefix). - mapper/matching.cfc: add $upperCaseAfterComma() and use it for named routes; it trims comma runs, removes them, and upper-cases the following word character using the same engine \w class as the old pattern. Output is identical on unpatched engines (guarded differential + explicit expected outputs in the specs). No public API change. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(controller): pass an explicit length to Mid for Adobe validation Adobe's MID parameter validation rejects the two-argument form used in the flash-key rewrite: onApplicationStart failed with FunctionArgMismatchException and every request returned 500 on adobe2023/adobe2025. Pass Len(item) - 6 explicitly; the resulting key is unchanged. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(controller): use RemoveChars for the flash key tail Follow-up to the Adobe MID fix: prefer RemoveChars(item, 1, 6) over the counted Mid tail (no zero-length slicing for the six-character "flashX" input), add the flash1 and flash- cases to redirectionSpec, guard the three follow-up files against reintroducing the \l/\U replacement case flags, and add non-ASCII entries to the named-route differential corpus. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com>
…ord fixture (#4653) - AdapterSqlGenerationSpec: add lower-case and whitespace-padded DATABASE_TO_LOWER="false"/DATABASE_TO_UPPER="false" cases. The lower branch returns before reading DATABASE_TO_UPPER, so the none branch's UCase(Trim()) normalisation was previously unexercised. - OrderReservedWordSpec: drop the blanket DELETE FROM c_o_r_e_casepreservation. The reads are scoped to the ids the test creates, and beforeAll now creates a sentinel row that the test must leave alone. This spec is the only writer to the table (propertyCasePreservationSpec reads metadata only), so removing the blanket delete cannot change another spec's data, and a future seeded fixture row is now safe. Signed-off-by: Peter Amiri <peter@alurium.com>
…4654) Phase 1 of #4629 item 3 (cross-adapter DDL quoting, H2 only). The H2 migrator now quotes generated-DDL identifiers in the datasource's own fold case, so migrations can create reserved-word tables and columns and still resolve existing unquoted schemas (H2 stored those folded). With folding off it keeps the legacy unquoted objectCase path. - add internal $ddlIdentifierCase()/$quoteDdlIdentifier() to migrator/Base (empty by default; other migrators inherit but never call them) - H2Migrator overrides them using the H2Model fold probe (cached per datasource, no duplicated classifier), quotes table/column names, and routes rename/renameColumn/changeColumn/dropForeignKey DDL through them - migratorObjectCase applies only when quoting is inactive Unit coverage uses H2MigratorCaseProbe for upper/lower/none; live H2 specs cover a reserved table/column, a legacy unquoted schema, up/down symmetry and the objectCase interaction. Non-H2 output is unchanged. Signed-off-by: Peter Amiri <peter@alurium.com>
…#4655) Phase 2 of #4629 item 3 (cross-adapter DDL quoting). The PostgreSQL and CockroachDB model adapters quote identifiers lowercase unconditionally, so the migrators now emit the same, using phase 1's Base seam ($ddlIdentifierCase() / $quoteDdlIdentifier()). - PostgreSQLMigrator: $ddlIdentifierCase() returns "lower"; quoteTableName() splits schema-qualified names and quotes each part; quoteColumnName() quotes one identifier (embedded double quotes doubled by the Base helper); remove the remaining objectCase wrappers in changeColumnInTable/dropForeignKeyFromTable. - CockroachDB inherits everything from PostgreSQLMigrator; no file change. - migratorObjectCase is ignored while quoting is active (always for PG/Cockroach). Other adapters are untouched. Unit coverage in AdapterSqlGenerationSpec for every inherited DDL path plus Cockroach inheritance; live PostgreSQL/Cockroach specs cover a reserved table/column, a legacy unquoted schema, up/down symmetry and the objectCase interaction. Signed-off-by: Peter Amiri <peter@alurium.com>
Phase 3 of #4629 item 3 (cross-adapter DDL quoting, final phase). Oracle model adapters quote identifiers uppercase unconditionally, so generated migration DDL now does the same through the phase 1 Base seam ($ddlIdentifierCase() / $quoteDdlIdentifier()). - OracleMigrator: $ddlIdentifierCase() returns "upper"; quoteTableName() splits schema-qualified names and quotes each part; quoteColumnName() quotes one identifier (embedded double quotes doubled by the Base helper); createTable quotes the table and TABLESPACE; the dropTable/dropView PL/SQL blocks, add column/foreign key, addIndex, removeIndex, renameTable and dropForeignKeyFromTable all route through the quote methods. - migratorObjectCase is ignored while quoting is active (always for Oracle). Other adapters are untouched. Unit coverage in AdapterSqlGenerationSpec for every Oracle DDL path and OracleMigratorDropSpec for the quoted PL/SQL drops; live Oracle specs cover a reserved table/column, a legacy unquoted schema, up/down symmetry and the objectCase interaction when Oracle infrastructure is available. Signed-off-by: Peter Amiri <peter@alurium.com>
…4661) tools/ci/cli-suite-request.sh created its status-code file with a bare mktemp. On macOS, /usr/bin/mktemp with no template writes to the per-user Darwin temp directory under /var/folders and ignores TMPDIR, so a caller whose sandbox only permits writes under its own TMPDIR (or /tmp) failed before the suite request was sent, and tools/test-cli-local.sh reported no results. Use "${TMPDIR:-/tmp}/cli-suite-code.XXXXXX", which honours TMPDIR on every platform and keeps the old behaviour when it is unset. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The deploy-ci change detector decides whether a pull request runs the CLI suite, but its path list missed two scripts that tools/test-cli-local.sh executes on every run: tools/ci/cli-suite-request.sh (sends the suite request) and tools/ci/fetch-sqlite-jdbc.sh (installs the SQLite driver when it is absent). A pull request touching only one of them skipped the suite that exercises it. Add both to the pattern. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The wheels analyze section described the report but never said which rules it applies, and pointed readers at a "top-10" list that no longer exists under that name. Name the anti-patterns directly and add a "What it checks" table with each rule id, category, severity and trigger, plus the thresholds (complexity 10, file length 500, parameters 5) and the paths the scanner skips, all taken from cli/lucli/services/Analysis.cfc. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The todo-comment code smell in wheels analyze matched TODO, FIXME, HACK, XXX and BUG with no word boundary after the marker, so ordinary comments such as "// BUGGY path", "// TODOS list", "<!--- HACKATHON demo --->" and "// XXXL size" were reported as technical debt. Anchor the marker with a word boundary before the optional colon. The real forms (// TODO: x, // FIXME x, <!--- HACK --->, // BUG: y, //TODO) are still detected. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): detect analyze todo markers in block and doc comments
The todo-comment code smell in wheels analyze only looked for markers
after // and <!---. A /* TODO ... */ block comment or a doc-comment
continuation line (" * FIXME ...") was never reported, although wheels
notes already finds both. Extend the pattern to those two forms, keep
the marker a whole word, and report a doc-comment match on its own line
(the match starts at the preceding newline).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
* docs(cli): describe the analyze todo-comment rule's comment forms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
---------
Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](fastify/fast-uri@v3.1.8...v3.1.9) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(rustcfml): bump engine pin to v0.744.0 * ci: trigger checks for the RustCFML pin bump Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: bpamiri <180555+bpamiri@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Migration-generated DDL now quotes identifiers on H2 (datasource fold case), PostgreSQL and CockroachDB (lowercase) and Oracle (uppercase), so reserved words such as order and group work as table and column names. Add a section to the migrations guide with a per-database table of the quoting and of whether migratorObjectCase still applies (MySQL, SQL Server and SQLite already quoted and still honour it), plus an example. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ct (#4666) tools/test-local.sh and tools/test-cli-local.sh accept LUCLI_HOME or WHEELS_HOME as an isolated CLI home. With only WHEELS_HOME set, the override reached the runtime through -Dlucli.home and user.home, but LUCLI_HOME was not exported, so a child process that reads it (a nested wheels call, or a spec shelling out) fell back to its own default home. Export the resolved override as LUCLI_HOME in override mode. Default mode is unchanged. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Run tools/changelog-promote.sh 4.2.1 2026-10-30 on develop 4c782de: 19 changelog.d fragments become the [4.2.1] section (Changed, Fixed). The two Adobe CF 2023 u26 / 2025 u14 lines lead the Fixed list, as the fix this release ships for. Add docs/api/v4.2.1.json. release.yml builds the wheels lookup index with --strict --version 4.2.1, which reads docs/api/v<version>.json and fails the release without it. The API reference the framework generates at 4c782de (snapshot run 41106853316) is identical to v4.2.0.json, so this is a copy of it. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Wheels Test Matrix
A ':x: N tests' cell means the leg reported fewer than 4000 testcases (suite never ran; counted as failed). ':x: no result' means the leg returned no parseable result. Results for commit 3dd7c88. |
Contributor
Wheels Test Results263 970 tests 257 673 ✅ 1h 50m 37s ⏱️ Results for commit 04bcda8. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 4.2.1
Cuts Wheels 4.2.1 from develop 04bcda8. This is a patch release with fixes only. See the
# [4.2.1]section ofCHANGELOG.md(#4667), whichrelease.ymlpublishes as the release notes.Headline fix: Adobe ColdFusion 2023 update 26 / 2025 update 14
These Adobe updates changed how regex replacements parse case escapes. On a patched Adobe engine, Wheels resolved a controller named
mainasumain, corrupted hyphenated controller/action names, route names and flash keys, and showed/wheels/infolabels with a strayu. Wheels now produces the same output on patched and unpatched engines (#4646: #4649, #4652; Discussion #4627).Other user-facing fixes
order,group) on H2, PostgreSQL, CockroachDB and Oracle: the migrators now quote DDL identifiers in each database's fold case (fix(model): H2 quoting follow-ups from #4628 (migrator DDL, tenant JOIN memo key, classifier coverage) #4629: fix(migrator): quote H2 DDL identifiers in the datasource fold case (#4629 phase 1) #4654, fix(migrator): quote PostgreSQL/CockroachDB DDL identifiers lowercase (#4629 phase 2) #4655, fix(migrator): quote Oracle DDL identifiers uppercase (#4629 phase 3) #4656). H2 queries quote identifiers in the datasource's fold case (fix(model): the H2 adapter doesn't quote identifiers, so reserved-word column names fail #4440).renderWith()JSON and XML rendering no longer throw on null values, and the date/time form helpers accept a null bound value (fix(controller): handle null values in renderWith() and date form helpers #4633, fix(controller): XML rendering and regression coverage follow-ups from #4633 #4638).select=expressions with comma-bearing SQL functions (CONCAT,COALESCE, …) are no longer corrupted withinclude=or a distinct group-by (Fix: $createSQLFieldListDuplicatePrefix and $groupByClause corrupt select clauses containing SQL functions with commas #4634).useTestDBhonorallowTestsAgainstPrimaryDatasource=true(fix(debugbar): DebugBar test runner and full runner link should respect allowTestsAgainstPrimaryDatasource / pass useTestDB=false #4642, fix(testing): $testDataSourceDecision should prioritize allowTestsAgainstPrimaryDatasource when useTestDB is omitted #4643)..envvalues coerce to numbers only when they are plain integers (007and1e3stay strings) (fix(config): coerce .env values to numbers only when they are plain integers #4606).wheels analyzereports TODO markers in block and doc comments, and only as whole words (fix(cli): match analyze todo-comment markers as whole words #4660, fix(cli): detect analyze todo markers in block and doc comments #4664).Changed
/and:stay literal, and"becomes"). Browsers render both forms the same, but tests that compare against hard-coded encoded strings may need updating. This comes from the engine's ESAPI 3.x, not from a Wheels change.Release checks
wheels.jsonis4.2.1;CHANGELOG.mdhas# [4.2.1](…) => 2026-10-30followed by a---separatordocs/api/v4.2.1.jsonis committed (the--strictdocs-index step needs it);build-docs-index.mjs --strict --version 4.2.1passesfeats; no breaking changes🤖 Generated with Claude Code