fix(model): exempt binary columns from scalar-column type guard in $setProperty#2668
Conversation
…etProperty `FileReadBinary()` and multipart file uploads surface byte content as a CFML array on BoxLang and some Lucee 6 configurations, while Lucee 7 and Adobe expose it as `byte[]`. The scalar-column guard in `$setProperty` was rejecting any array bound for a real DB column without consulting the column type, so binary uploads to `blob` / `bytea` / `longblob` columns failed at the model layer before ever reaching JDBC. Binary columns now bypass the guard; the array shape passes through to the underlying driver. Fixes #2660. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…pload pattern - .ai/wheels/cross-engine-compatibility.md: add "Binary Data Representation" section noting that BoxLang / Lucee 6 surface FileReadBinary() as a CFML array while Lucee 7 / Adobe expose byte[]; Wheels' $setProperty exempts binary columns from the scalar-column type guard so both shapes pass through to JDBC (fix #2660). - web/guides file-uploads-and-downloads.mdx: add "Storing binary content in a database column" section showing how to assign FileReadBinary() output to a blob model property, with an Aside noting the cross-engine shape difference. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Framework
CFML MVC framework with ActiveRecord ORM. Models in app/models/, controllers in app/controllers/, views in app/views/, migrations in app/migrator/migrations/, config in config/, tests in tests/.
Directory Layout
app/controllers/ app/models/ app/views/ app/views/layout.cfm
app/migrator/migrations/ app/db/seeds.cfm app/db/seeds/
app/events/ app/global/ app/lib/
app/mailers/ app/jobs/ app/plugins/ app/snippets/
config/settings.cfm config/routes.cfm config/environment.cfm
plugins/ public/ tests/ vendor/ .env (never commit)
Development Tools
Prefer MCP tools when the Wheels MCP server is available (mcp__wheels__*). Fall back to CLI otherwise.
| Task | MCP | CLI |
|---|---|---|
| Generate | wheels_generate(type, name, attributes) |
wheels g model/controller/scaffold Name attrs |
| Migrate | wheels_migrate(action="latest|up|down|info") |
wheels migrate latest|up|down|info |
| Test | wheels_test() |
wheels test run |
| Reload | wheels_reload() |
?reload=true&password=... |
| Server | wheels_server(action="status") |
wheels start|stop|status |
| Analyze | wheels_analyze(target="all") |
— |
| Admin | — | wheels g admin ModelName |
| Seed | — | wheels seed (legacy alias: wheels db:seed) |
Critical Anti-Patterns (Top 10)
These are the most common mistakes when generating Wheels code. Check every time.
1. Mixed Argument Styles
Wheels functions cannot mix positional and named arguments. This is the #1 error source.
// WRONG — mixed positional + named
hasMany("comments", dependent="delete");
validatesPresenceOf("name", message="Required");
// RIGHT — all named when using options
hasMany(name="comments", dependent="delete");
validatesPresenceOf(properties="name", message="Required");
// RIGHT — positional only (no options)
hasMany("comments");
validatesPresenceOf("name");2. Query vs Array Confusion in Views
Model finders return query objects, not arrays. Loop accordingly.
// WRONG
<cfloop array="#users#" index="user">
// RIGHT
<cfloop query="users">
#users.firstName#
</cfloop>3. Nested Resource Routes — Use Callback Syntax
Wheels supports nested resources via the callback parameter or nested=true with manual end(). Do NOT use Rails-style inline function blocks.
// WRONG — Rails-style inline (not supported)
.resources("posts", function(r) { r.resources("comments"); })
// RIGHT — callback syntax (recommended)
.resources(name="posts", callback=function(map) {
map.resources("comments");
})
// RIGHT — manual nested=true + end()
.resources(name="posts", nested=true)
.resources("comments")
.end()
// RIGHT — flat separate declarations (no URL nesting)
.resources("posts")
.resources("comments")4. HTML5 Form Helpers Available
Wheels provides dedicated HTML5 input helpers. Use them instead of manual type attributes.
// Object-bound helpers
#emailField(objectName="user", property="email")#
#urlField(objectName="user", property="website")#
#numberField(objectName="product", property="quantity", min="1", max="100")#
#telField(objectName="user", property="phone")#
#dateField(objectName="event", property="startDate")#
#colorField(objectName="theme", property="primaryColor")#
#rangeField(objectName="settings", property="volume", min="0", max="100")#
#searchField(objectName="search", property="query")#
// Tag-based helpers
#emailFieldTag(name="email", value="")#
#numberFieldTag(name="qty", value="1", min="0", step="1")#5. Migration Seed Data — Use Direct SQL
Parameter binding in execute() is unreliable. Use inline SQL for seed data.
// WRONG
execute(sql="INSERT INTO roles (name) VALUES (?)", parameters=[{value="admin"}]);
// RIGHT
execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', NOW(), NOW())");6. Route Order Matters
Routes are matched first-to-last. Wrong order = wrong matches.
Order: MCP routes → resources → custom named routes → root → wildcard (last!)
7. timestamps() Includes createdAt, updatedAt, and deletedAt
Don't also add separate datetime columns for these.
// WRONG — duplicates
t.timestamps();
t.datetime(columnNames="createdAt");
// RIGHT
t.timestamps(); // creates createdAt, updatedAt, AND deletedAt (soft-delete)Note: t.timestamps() adds three columns, not two — the third is the soft-delete marker. Verified against vendor/wheels/migrator/TableDefinition.cfc.
8. Database-Agnostic Dates in Migrations
Use NOW() — it works across MySQL, PostgreSQL, SQL Server, H2, SQLite.
// WRONG — database-specific
execute("INSERT INTO users (name, createdAt) VALUES ('Admin', CURRENT_TIMESTAMP)");
// RIGHT
execute("INSERT INTO users (name, createdAt, updatedAt) VALUES ('Admin', NOW(), NOW())");9. Controller Filters Must Be Private
Filter functions (authentication, data loading) must be declared private.
// WRONG — public filter becomes a routable action
function authenticate() { ... }
// RIGHT
private function authenticate() { ... }10. Always cfparam View Variables
Every variable passed from controller to view needs a cfparam declaration.
// At top of every view file
<cfparam name="users" default="">
<cfparam name="user" default="">Wheels Conventions
- config(): All model associations/validations/callbacks and controller filters/verifies go in
config() - Naming: Models are singular PascalCase (
User.cfc), controllers are plural PascalCase (Users.cfc), table names are plural lowercase (users) - Parameters:
params.keyfor URL key,params.userfor form struct,params.user.firstNamefor nested - extends: Models extend
"Model", controllers extend"Controller", tests extend"wheels.WheelsTest"(legacy:"wheels.Test"for RocketUnit) - Associations: All named params when using options:
hasMany(name="orders"),belongsTo(name="user"),hasOne(name="profile") - Validations: Property param is
property(singular) for single,properties(plural) for list:validatesPresenceOf(properties="name,email")
Model Quick Reference
component extends="Model" {
function config() {
// Table/key (only if non-conventional)
tableName("tbl_users");
setPrimaryKey("userId");
// Associations — all named params when using options
hasMany(name="orders", dependent="delete");
belongsTo(name="role");
// Validations
validatesPresenceOf("firstName,lastName,email");
validatesUniquenessOf(property="email");
validatesFormatOf(property="email", regEx="^[\w\.-]+@[\w\.-]+\.\w+$");
// Callbacks
beforeSave("sanitizeInput");
// Query scopes — reusable, composable query fragments
scope(name="active", where="status = 'active'");
scope(name="recent", order="createdAt DESC");
scope(name="byRole", handler="scopeByRole"); // dynamic scope
// Enums — named values with auto-generated checkers and scopes
enum(property="status", values="draft,published,archived");
enum(property="priority", values={low: 0, medium: 1, high: 2});
}
// Dynamic scope handler (must return struct with query keys)
private struct function scopeByRole(required string role) {
return {where: "role = '#arguments.role#'"};
}
}Finders: model("User").findAll(), model("User").findOne(where="..."), model("User").findByKey(params.key).
Create: model("User").new(params.user) then .save(), or model("User").create(params.user).
Include associations: findAll(include="role,orders"). Pagination: findAll(page=params.page, perPage=25).
Scopes (Composable Query Fragments)
// Chain scopes together — each adds to the query
model("User").active().recent().findAll();
model("User").byRole("admin").findAll(page=1, perPage=25);
model("User").active().recent().count();Chainable Query Builder (Injection-Safe)
// Fluent alternative to raw WHERE strings — values are auto-quoted
model("User")
.where("status", "active")
.where("age", ">", 18)
.whereNotNull("emailVerifiedAt")
.orderBy("name", "ASC")
.limit(25)
.get();
// Combine with scopes
model("User").active().where("role", "admin").get();
// Other builder methods: orWhere, whereNull, whereBetween, whereIn, whereNotInEnums (Named Property Values)
// Auto-generated boolean checkers
user.isDraft(); // true/false
user.isPublished(); // true/false
// Auto-generated scopes per value
model("User").draft().findAll();
model("User").published().findAll();Batch Processing (Memory-Efficient)
// Process one record at a time (loads in batches internally)
model("User").findEach(batchSize=1000, callback=function(user) {
user.sendReminderEmail();
});
// Process in batch groups (callback receives query/array)
model("User").findInBatches(batchSize=500, callback=function(users) {
processUserBatch(users);
});
// Works with scopes and conditions
model("User").active().findEach(batchSize=500, callback=function(user) { /* ... */ });Middleware Quick Reference
Middleware runs at the dispatch level, before controller instantiation. Each implements handle(request, next).
// config/settings.cfm — global middleware (runs on every request)
set(middleware = [
new wheels.middleware.RequestId(),
new wheels.middleware.SecurityHeaders(),
new wheels.middleware.Cors(allowOrigins="https://myapp.com")
]);// config/routes.cfm — route-scoped middleware
mapper()
.scope(path="/api", middleware=["app.middleware.ApiAuth"])
.resources("users")
.end()
.end();Built-in: wheels.middleware.RequestId, wheels.middleware.Cors, wheels.middleware.SecurityHeaders, wheels.middleware.RateLimiter. Custom middleware: implement wheels.middleware.MiddlewareInterface, place in app/middleware/.
DI Container Quick Reference
Register services in config/services.cfm (loaded at app start, environment overrides supported):
var di = injector();
di.map("emailService").to("app.lib.EmailService").asSingleton();
di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();
di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();Resolve with service() anywhere, or use inject() in controller config():
// In any controller/view
var svc = service("emailService");
// Declarative injection in controller config()
function config() {
inject("emailService, currentUser");
}
function create() {
this.emailService.send(to=user.email); // resolved per-request
}Scopes: transient (default, new each call), .asSingleton() (app lifetime), .asRequestScoped() (per-request via request.$wheelsDICache). Auto-wiring: init() params matching registered names are auto-resolved when no initArguments passed. bind() = semantic alias for map().
Rate Limiting
// Fixed window (default) — 60 requests per 60 seconds
new wheels.middleware.RateLimiter()
// Sliding window — smoother enforcement
new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=120, strategy="slidingWindow")
// Token bucket — allows bursts up to capacity, refills steadily
new wheels.middleware.RateLimiter(maxRequests=50, windowSeconds=60, strategy="tokenBucket")
// Database-backed storage (auto-creates wheels_rate_limits table)
new wheels.middleware.RateLimiter(storage="database")
// Custom key function (rate limit per API key instead of IP)
new wheels.middleware.RateLimiter(keyFunction=function(req) {
return req.cgi.http_x_api_key ?: "anonymous";
})Strategies: fixedWindow (default), slidingWindow, tokenBucket. Storage: memory (default) or database. Adds X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers. Returns 429 Too Many Requests with Retry-After when limit exceeded.
Package System
Optional first-party modules are distributed as standalone repositories and installed into vendor/<name>/. The framework auto-discovers vendor/*/package.json on startup via PackageLoader.cfc with per-package error isolation.
Public author-facing guide: Packages — manifest fields, mixin targets, lifecycle, service providers, lazy loading, testing, publishing flow. Submission workflow: wheels-packages/CONTRIBUTING.md.
Six first-party packages live in standalone repos under wheels-dev/, indexed by the wheels-dev/wheels-packages registry:
wheels-dev/wheels-sentry— error trackingwheels-dev/wheels-hotwire— Turbo/Stimuluswheels-dev/wheels-basecoat— UI componentswheels-dev/wheels-legacy-adapter— 3.x → 4.x compatibility shimswheels-dev/wheels-i18n— internationalization (JSON or DB-backed translations, pluralization)wheels-dev/wheels-seo-suite— SEO tooling (meta tags, Open Graph, sitemaps, robots.txt, debug panel)
vendor/ # Runtime: framework core + installed packages
wheels/ # Framework core (excluded from package discovery)
wheels-sentry/ # Installed package
plugins/ # DEPRECATED: legacy plugins still work with warning
package.json Manifest
{
"name": "wheels-sentry",
"version": "1.0.0",
"author": "PAI Industries",
"description": "Sentry error tracking",
"wheelsVersion": ">=3.0",
"provides": {
"mixins": "controller",
"services": [],
"middleware": []
},
"dependencies": {}
}provides.mixins: Comma-delimited targets from the allowlist application,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test, plus the special values global (inject into all targets) and none (explicit opt-out). Determines which framework components receive the package's public methods. Default: none (explicit opt-in, unlike legacy plugins which default to global). Unknown targets (typos, view, service, etc.) are rejected with a clear error — view helpers belong in controller mixins since Wheels views execute in the controller's variables scope.
Installing a Package
Use the wheels packages CLI. Resolves names against the wheels-dev/wheels-packages registry, verifies sha256, extracts to vendor/<name>/.
wheels packages list # browse the registry
wheels packages search <query> # name/description/tag match
wheels packages show <name> # detail page
wheels packages add <name> # latest compat version (canonical verb)
wheels packages add <name>@<version> # pin
wheels packages add <name> --force # overwrite an existing vendor/<name>
wheels packages update <name> --yes # explicit update
wheels packages update --all --yes # update every installed package
wheels packages remove <name> # delete vendor/<name>
wheels packages registry refresh # bust the 24h cache
wheels packages registry info # show registry URL + cache stateOverride the registry with WHEELS_PACKAGES_REGISTRY=<org>/<repo> (defaults to wheels-dev/wheels-packages). Restart or wheels reload after install.
Error Isolation
Each package loads in its own try/catch. A broken package is logged and skipped — the app and other packages continue normally.
Testing Packages
# Run a specific package's tests (package must be in vendor/)
curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.wheels-sentry.tests"Routing Quick Reference
// config/routes.cfm
mapper()
.resources("users") // standard CRUD
.resources("products", except="delete") // skip actions
.resources(name="posts", callback=function(map) { // nested resources
map.resources("comments");
map.resources("tags");
})
.get(name="login", to="sessions##new") // named route
.post(name="authenticate", to="sessions##create")
.root(to="home##index", method="get") // homepage
.wildcard() // keep last!
.end();Helpers: linkTo(route="user", key=user.id, text="View"), urlFor(route="users"), redirectTo(route="user", key=user.id), startFormTag(route="user", method="put", key=user.id).
Route Model Binding
Automatically resolves params.key into a model instance before the controller action runs. The instance lands in params.<singularModelName> (e.g., params.user). Throws Wheels.RecordNotFound (404) if the record doesn't exist; silently skips if the model class doesn't exist.
// Per-resource — convention: singularize controller name → model
.resources(name="users", binding=true)
// Explicit model name override
.resources(name="posts", binding="BlogPost") // resolves BlogPost, stored in params.blogPost
// Scope-level — all nested resources inherit binding
.scope(path="/api", binding=true)
.resources("users") // params.user
.resources("products") // params.product
.end()
// Global — enable for all resource routes
set(routeModelBinding=true); // in config/settings.cfmIn the controller, use the resolved instance directly:
function show() {
user = params.user; // already a model object, no findByKey needed
}Pagination View Helpers
Requires a paginated query: findAll(page=params.page, perPage=25). The recommended all-in-one helper is paginationNav().
// All-in-one nav (wraps first/prev/page-numbers/next/last in <nav>)
#paginationNav()#
#paginationNav(showInfo=true, showFirst=false, showLast=false, navClass="my-pagination")#
// Individual helpers for custom layouts
#paginationInfo()# // "Showing 26-50 of 1,000 records"
#firstPageLink()# // link to page 1
#previousPageLink()# // link to previous page
#pageNumberLinks()# // windowed page number links (default windowSize=2)
#nextPageLink()# // link to next page
#lastPageLink()# // link to last page
#pageNumberLinks(windowSize=5, classForCurrent="active")#Disabled links render as <span class="disabled"> by default. All helpers accept handle for named pagination queries.
Testing Quick Reference
All new tests use WheelsTest BDD syntax. RocketUnit (test_ prefix, assert()) is legacy only — never use it for new tests.
Two test suites
- App tests:
/wheels/app/tests— project-specific tests intests/specs/. Usestests/populate.cfmfor test data andtests/TestRunner.cfcfor setup. - Core tests:
/wheels/core/tests— framework tests invendor/wheels/tests/specs/. Usesvendor/wheels/tests/populate.cfm. This is what CI runs across all engines × databases.
Critical: Core tests use directory="wheels.tests.specs" which compiles EVERY CFC in the directory. One compilation error in any spec file crashes the entire suite for that engine.
// tests/specs/models/MyFeatureSpec.cfc
component extends="wheels.WheelsTest" {
function run() {
describe("My Feature", () => {
it("validates presence of name", () => {
var user = model("User").new();
expect(user.valid()).toBeFalse();
});
});
}
}- Specs:
tests/specs/models/,tests/specs/controllers/,tests/specs/functional/ - Test models:
tests/_assets/models/(usetable()to map to test tables) - Test data:
tests/populate.cfm(DROP + CREATE tables, seed data) - Runner URL:
/wheels/app/tests?format=json&directory=tests.specs.models - Force reload: append
&reload=trueafter adding new model CFCs - Closure gotcha: CFML closures can't access outer
localvars — use shared structs (var result = {count: 0}) - Scope gotcha in test infra: Wheels internal functions (
$dbinfo,model(), etc.) aren't available as bare calls in.cfmfiles included from plain CFCs likeTestRunner.cfc. Useapplication.wo.model()or native CFML tags (cfdbinfo). #escape gotcha: HTML entities likeocontain#which CFML interprets as expression delimiters. In string literals, escape as&##111;. Comments (//) are fine since they aren't evaluated. Unescaped#in strings causes "Invalid Syntax Closing [#] not found" compilation errors that crash the entire test suite (not just that file).$clearRoutes()in test specs: Test CFCs that manipulate routes must define their own$clearRoutes()method — it is NOT inherited fromwheels.WheelsTest. Copy fromlinksSpec.cfc.Left(str, 0)crashes Lucee 7: Use a ternary guard:local.match.pos[1] > 1 ? Left(str, local.match.pos[1] - 1) : ""- Run with MCP
wheels_test()or CLIwheels test run
Running Tests Locally (Wheels CLI — Recommended)
IMPORTANT: Always run the test suite before pushing. Do not rely on CI alone.
wheelsIS the CLI. Wheels is built on the LuCLI runtime, but we ship the runtime under thewheelsbrand. End users only ever interact with the CLI aswheels— there is no separateluclibinary on a normal install. When older docs or scripts mention "install LuCLI" or invokelucli, they pre-date the rebrand and are being migrated towheels.
Fastest method: one command
bash tools/test-local.sh # run all core tests
bash tools/test-local.sh model # run model tests only
bash tools/test-local.sh security # run security tests onlyThe script handles everything: creates SQLite DBs, starts a Wheels CLI server if needed, runs tests, reports results, cleans up. No Docker required.
Prerequisites (one-time setup)
# Install the Wheels CLI (4.0.0+ recommended)
brew install wheels # or download from GitHub releases
# Java 21 required
brew install openjdk@21Manual method (if you need a persistent server)
cd /path/to/wheels
sqlite3 wheelstestdb.db "SELECT 1;"
sqlite3 wheelstestdb_tenant_b.db "SELECT 1;"
wheels start --port=8080
# In another terminal:
curl -s "http://localhost:8080/?reload=true&password=wheels"
curl -sf "http://localhost:8080/wheels/core/tests?db=sqlite&format=json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(f'{d[\"totalPass\"]} pass, {d[\"totalFail\"]} fail, {d[\"totalError\"]} error')"Run specific test directories
bash tools/test-local.sh model # vendor/wheels/tests/specs/model/
bash tools/test-local.sh controller # vendor/wheels/tests/specs/controller/
bash tools/test-local.sh view # vendor/wheels/tests/specs/view/
bash tools/test-local.sh security # vendor/wheels/tests/specs/security/
bash tools/test-local.sh middleware # vendor/wheels/tests/specs/middleware/
bash tools/test-local.sh dispatch # vendor/wheels/tests/specs/dispatch/
bash tools/test-local.sh migrator # vendor/wheels/tests/specs/migrator/Running Tests Locally (Docker matrix)
Docker is the authoritative way to reproduce CI's compat-matrix.yml workflow
(every engine × every database) before pushing. Source is bind-mounted via
compose.yml at ./:/wheels-test-suite, so edit-reload-test
cycles don't require image rebuilds — only the Wheels application reloads
between iterations.
tools/test-matrix.sh — local mirror of compat-matrix.yml
tools/test-matrix.sh # Lucee 7 + SQLite (happy path, fastest)
tools/test-matrix.sh lucee7 mysql # Lucee 7 + MySQL
tools/test-matrix.sh lucee7 sqlite,mysql # Multiple DBs against one engine
tools/test-matrix.sh lucee6,lucee7 sqlite # Multiple engines against one DB
tools/test-matrix.sh --all # Full matrix (every engine × every DB)
tools/test-matrix.sh --rebuild lucee7 # Force `docker compose build` (image cache stale)
tools/test-matrix.sh --down # Tear everything downMirrors CI exactly: engine + DB containers come up under
COMPOSE_PROJECT_NAME=wheels (so containers are named wheels-<service>-1,
matching every assertion in compat-matrix.yml); engine restarts between DB
runs to clear cached model metadata; warmup curl before each test run; same
test URL (/wheels/core/tests?db=<db>&format=json); same JSON parsing.
Default behavior: containers stay running between invocations (fast iteration
for repeated runs against the same engine/DB). Edit framework code → --reload
isn't needed if you're hitting the test endpoint, since wheels/core/tests
re-evaluates each request. For full app reload (model metadata, package
discovery): curl "http://localhost:<port>/?reload=true&password=wheels".
Engines and ports (mirror compat-matrix.yml matrix)
| Engine | Port |
|---|---|
| lucee6 | 60006 |
| lucee7 | 60007 |
| adobe2023 | 62023 |
| adobe2025 | 62025 |
| boxlang | 60001 |
compose.yml also defines lucee5, adobe2018, adobe2021 services for
historical reasons; they are NOT in the CI matrix and should be considered
unsupported for new development.
Databases (mirror compat-matrix.yml DATABASES env)
sqlite, h2 (Lucee only), mysql, postgres, sqlserver, cockroachdb,
oracle. SQLite and H2 are file-based (no container needed). The rest spawn
their own service containers.
Manual ad-hoc invocations (skip the wrapper)
If you want to script something the wrapper doesn't cover, the underlying
moves are documented in .github/workflows/compat-matrix.yml.
Always set COMPOSE_PROJECT_NAME=wheels first so container names match CI.
export COMPOSE_PROJECT_NAME=wheels
docker compose up -d lucee7 mysql
# wait for ready (see compat-matrix.yml lines 79-124 for canonical readiness check)
curl -s "http://localhost:60007/wheels/core/tests?db=mysql&format=json&directory=tests.specs.controller" > /tmp/results.jsonKnown cross-engine gotchas
Always verify Adobe CF fixes locally before pushing — don't iterate via CI. Test against the local container directly:
curl -s "http://localhost:62023/wheels/core/tests?db=mysql&format=json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass',0),'pass',d.get('totalFail',0),'fail',d.get('totalError',0),'error')"- struct.map(): Lucee/Adobe resolve
obj.map()as the built-in struct member function, not the CFC method. UsemapInstance()on the Injector. - Application scope: Adobe CF doesn't support function members on the
applicationscope. Pass a plain struct context instead. - Closure this: CFML closures capture
thisfrom the declaring scope. Usevar ctx = {ref: obj}to share references across closures. - Bracket-notation function call:
obj["key"]()crashes Adobe CF 2021/2023 parser inside closures. Split into two statements:var fn = obj["key"]; fn(). - Inline closure as constructor named arg:
new Foo(callback = function(){...})crashes Adobe CF withArrayStoreException: ASTcffunctionand takes down the entire TestBox bundle becausegetComponentMetadata()triggers eager compilation of every CFC in the directory. Hoist the closure into a local var first:var fn = function(){...}; new Foo(callback = fn). No behavior change on Lucee/BoxLang. - Array by-value in struct literals: Adobe CF copies arrays by value in
{arr = myArray}. Closures that append to the copy won't affect the original. Reference via parent struct instead:{owner = parentStruct}thenowner.arr. privatemixin functions not integrated:$integrateComponents()only copiespublicmethods into model/controller objects. ALL helper functions in mixin CFCs (vendor/wheels/model/*.cfc, view helpers, etc.) MUST usepublicaccess. Use$prefix for internal scope instead ofprivatekeyword. BoxLang handles this differently, soprivatemay pass BoxLang tests but fail Lucee/Adobe.
CI soft-fail databases
SOFT_FAIL_DBS in .github/workflows/compat-matrix.yml (lines 389, 519) is currently empty ("") — all databases, including CockroachDB, are hard-gated in CI. To mark a database as soft-fail (failures logged as warnings but not blocking the build), add it to SOFT_FAIL_DBS in both locations. Remove a database from the list once its tests are fixed.
Cleanup
tools/test-matrix.sh --down # Stop and remove all containers + networkLocal Onboarding Harness
tools/test-onboarding.sh simulates the brand-new-user fresh-install flow without
touching the user's daily wheels install. It is the right tool when:
- Fixing CLI / framework / template code that affects the
wheels new→
wheels start→wheels migrate latestcliff. - Validating cliff fixes BEFORE asking for a fresh-VM tutorial run.
- Iterating on dotted-path resolution, Lucee bundle issues, or generated
config emission.
bash tools/test-onboarding.sh # symlink-mount worktree (default)
MODE=copy bash tools/test-onboarding.sh # closer to brew-install simulation
BASELINE=1 bash tools/test-onboarding.sh # use the brew-installed wheels
KEEP_TEMP=1 bash tools/test-onboarding.sh # preserve temp dirs for inspection
FROM_PHASE=4 bash tools/test-onboarding.sh # skip earlier phases when iteratingThe harness uses LUCLI_HOME isolation (writes only into mktemp -d), reuses
the user's existing Lucee Express via symlink to skip the ~74MB redownload, and
runs ~90 seconds end-to-end through 7 phases mirroring the fresh-VM onboarding
journal format. Output is directly comparable to fresh-VM run reports.
| Phase | Covers | Fresh-VM findings |
|---|---|---|
| 1 | Setup isolated LUCLI_HOME, framework path, Lucee Express symlink |
— |
| 2 | wheels new (no duplicate create lines, file tree, no bundleName) |
F1, F3, F4 |
| 3 | Server boot via wheels start + sqlite-jdbc shim |
(formula simulation) |
| 4 | Migration cliff — verify the actual sqlite db has tables, not just exit 0 | F2, F5 |
| 5 | Seed (cfscript wrapper + seedOnce idempotency) |
F3-orig |
| 6 | CRUD walkthrough (tutorial chapters 2-3 happy path) | tutorial verification |
| 7 | wheels packages list |
F7 (currently SKIP pending follow-up) |
Output uses ✓ / ✗ / - per check. A green local run is a strong predictor
of a green fresh-VM run; the SKIP markers signal known pending issues that are
expected to fail until their respective follow-up PRs ship.
Deeper reference: .ai/wheels/testing/onboarding-harness.md.
Auto-Migration Quick Reference
Generate migrations from model/DB schema diffs. Rename detection via explicit hints (authoritative) + heuristic suggestions (normalized-token + Levenshtein).
// Programmatic
var am = CreateObject("component", "wheels.migrator.AutoMigrator");
// Single model
var d = am.diff("User");
var d = am.diff("User", {renames: {"full_name": "fullName"}});
var d = am.diff("User", {heuristicThreshold: 0.85});
// All models (per-model hints keyed by model name)
var all = am.diffAll({
hints: {"User": {renames: {"full_name": "fullName"}}},
heuristicThreshold: 0.7
});
// Write migration CFC from diff result
am.writeMigration(d, "rename_name_field");# CLI
wheels dbmigrate diff User # preview
wheels dbmigrate diff User --rename=full_name:fullName # with hint
wheels dbmigrate diff User --write --name=rename_name # commit file
wheels dbmigrate diff --threshold=0.85 # all models, stricter
wheels dbmigrate diff --rename=User.full_name:fullName # diffAll hintDiff result struct:
{modelName, tableName,
addColumns, removeColumns, changeColumns, // pruned of rename pairs
renameColumns, // confirmed renames (emitted into up/down)
suggestedRenames} // heuristic candidates for display
Limits: PK renames not detected; rename + type change requires separate migrations; calculated properties excluded from diff.
Database Seeding Quick Reference
Convention-based, idempotent seeding with CLI support.
// app/db/seeds.cfm — Shared seeds (runs in all environments)
seedOnce(modelName="Role", uniqueProperties="name", properties={
name: "admin", description: "Administrator"
});
seedOnce(modelName="Role", uniqueProperties="name", properties={
name: "member", description: "Regular member"
});
// app/db/seeds/development.cfm — Dev-only seeds (runs after seeds.cfm)
seedOnce(modelName="User", uniqueProperties="email", properties={
firstName: "Dev", lastName: "User", email: "dev@example.com"
});CLI (canonical Wheels CLI form; wheels db:seed is the legacy CommandBox alias — prefer the short form):
wheels seed # Run convention seeds (auto-detect env)
wheels seed --environment=production # Seed for specific environment
wheels seed --generate # Legacy: random test data
wheels generate seed # Create app/db/seeds.cfm
wheels generate seed --all # Create seeds.cfm + dev/prod stubsNote: the --count / --models / --dataFile flags on --generate only exist on the legacy CommandBox wheels db:seed surface; the Wheels CLI's wheels seed ignores them.
seedOnce() — idempotent: checks uniqueProperties via findOne(), creates only if not found. Re-running seeds is always safe.
Execution order: app/db/seeds.cfm (shared) → app/db/seeds/<environment>.cfm (env-specific). Wrapped in a transaction.
Seeder component: application.wheels.seeder (initialized alongside migrator). Call application.wheels.seeder.runSeeds() programmatically.
Background Jobs Quick Reference
// Define a job: app/jobs/SendWelcomeEmailJob.cfc
component extends="wheels.Job" {
function config() {
super.config();
this.queue = "mailers";
this.maxRetries = 5;
}
public void function perform(struct data = {}) {
sendEmail(to=data.email, subject="Welcome!", from="app@example.com");
}
}
// Enqueue from a controller
job = new app.jobs.SendWelcomeEmailJob();
job.enqueue(data={email: user.email}); // immediate
job.enqueueIn(seconds=300, data={email: "..."}); // delayed 5 minutes
job.enqueueAt(runAt=scheduledDate, data={}); // at specific time
// Process jobs (call from scheduled task or controller)
job = new wheels.Job();
result = job.processQueue(queue="mailers", limit=10);
// Queue management
stats = job.queueStats(); // {pending, processing, completed, failed, total}
job.retryFailed(queue="mailers"); // retry all failed jobs
job.purgeCompleted(days=7); // clean up old completed jobsJob Worker CLI — persistent daemon for processing jobs:
wheels jobs work # process all queues
wheels jobs work --queue=mailers --interval=3 # specific queue, 3s poll
wheels jobs status # per-queue breakdown
wheels jobs status --format=json # JSON output
wheels jobs retry --queue=mailers # retry failed jobs
wheels jobs purge --completed --failed --older-than=30
wheels jobs monitor # live dashboardConfigurable backoff: this.baseDelay = 2 and this.maxDelay = 3600 in job config(). Formula: Min(baseDelay * 2^attempt, maxDelay).
The wheels_jobs table is auto-created by Job.cfc::$ensureJobTable() on first enqueue or processing — no migration needed. (The older 20260221000001_createwheels_jobs_table.cfc migration is vestigial; Phase 2b drift audit confirmed auto-create is now the path.)
Deploy Quick Reference
wheels deploy ships your Dockerized Wheels app to production Linux servers via SSH. Ported from Basecamp Kamal's developer CLI — same config/deploy.yml schema, same on-server conventions (container names, labels, network, lock path), invokes the same kamal-proxy Go binary for zero-downtime rollover. No Ruby runtime required.
wheels deploy init # scaffold config/deploy.yml + .kamal/secrets
wheels deploy setup # one-time server bootstrap + first deploy
wheels deploy # rolling deploy
wheels deploy --dry-run # print commands without executing
wheels deploy rollback v1 # roll back to a previous version
wheels deploy config # print resolved config as YAML
wheels deploy version # show Kamal version this port mirrors
Subcommands
wheels deploy app <verb> // boot/start/stop/details/containers/images/logs/live/maintenance/remove
wheels deploy proxy <verb> // boot/reboot/start/stop/restart/details/logs/remove
wheels deploy accessory <verb> // boot/reboot/start/stop/restart/details/logs/remove (sidecars: db/redis/search)
wheels deploy build <verb> // deliver/push/pull/create/remove/details/dev
wheels deploy registry <verb> // setup/login/logout/remove
wheels deploy server <verb> // exec/bootstrap
wheels deploy prune <verb> // all/images/containers [--keep=N]
wheels deploy lock <verb> // acquire/release/status (manual — normal deploys auto-lock)
wheels deploy secrets <verb> // fetch/extract/print (adapters: op/bitwarden/aws/lastpass/doppler)
wheels deploy audit // tail /tmp/kamal-audit.log on each server
wheels deploy details // aggregate app + proxy + accessory status
wheels deploy remove --confirm // teardown all app/proxy/accessory containers
wheels deploy docs [section] // in-terminal config referenceOn-server parity contract (byte-compatible with Ruby Kamal)
- Container names:
<service>-<role>-<version> - Labels:
service=,role=,destination=,version= - Docker network:
kamal - Lock file:
/tmp/kamal_deploy_lock_<service> - Proxy config:
/home/<user>/.config/kamal-proxy/ - Hook env prefix:
KAMAL_*(neverWHEELS_*— user hooks migrate unchanged)
A server managed by Ruby Kamal can be taken over by wheels deploy without cleanup.
Architecture
cli/lucli/services/deploy/
├── cli/*.cfc DeployMainCli + Deploy<App|Proxy|Accessory|Build|Registry|Server|Prune|Lock|Secrets>Cli
├── commands/*.cfc Base + Docker/App/Proxy/Builder/Registry/Auditor/Lock/Hook/Accessory/PruneCommands
├── config/*.cfc Config + Role/Env/Builder/Proxy/Registry/Ssh/Accessory/Validator/ConfigLoader
├── lib/*.cfc JarLoader/Mustache/Yaml/SshClient/SshPool/FakeSshPool/Output/SecretResolver
└── secrets/*.cfc BaseAdapter + OnePassword/Bitwarden/AwsSecrets/LastPass/Doppler adapters
cli/lucli/lib/deploy/*.jar jmustache, snakeyaml, sshj + BouncyCastle transitives (URLClassLoader-isolated)
cli/lucli/templates/deploy/ Mustache templates for `wheels deploy init` output
Commands-are-strings invariant: every *Commands.cfc method returns a shell-command string; only *Cli.cfc and the orchestrator execute them. That's why --dry-run is trivial and unit tests run without network.
Critical gotchas
- Kamal-compatible schema, ONE divergence. ERB in
deploy.ymlis NOT supported (rendering it would require embedding a Ruby runtime). Kamal's native${VAR}env-var interpolation is preserved unchanged — uppercase-snake tokens resolve viaenvOverride → .kamal/secrets → System.getenv → ""(seeConfigLoader.$interpolate). Mustache ({{...}}) is used only bywheels deploy initto scaffold a freshdeploy.yml/secrets; it is NOT applied todeploy.ymlat runtime. Everything else inconfig/deploy.ymlis byte-identical to Kamal 2.4.0. - Hook env prefix is
KAMAL_, notWHEELS_. This is deliberate — it means Ruby Kamal users' existing.kamal/hooks/scripts work unchanged. app live/app maintenanceuse a marker file (/tmp/kamal-maintenance-<svc>) rather than kamal-proxy native maintenance mode. Phase 2 simplification; Phase 3 follow-up will align with Kamal's proxy-native semantics.wheels deploy removeis destructive and requires--confirm. Barewheels deploy removethrows without touching anything.- Lucee reserved scope names in subagent-authored deploy code.
client,session,application— usessh/sc,sess,appinstead. Bit us multiple times during the port. - No
--dry-runflag in Ruby Kamal 2.4.0. Thetools/deploy-config-diff.shharness compares config-layer output only. Byte-identical command-string parity is aspirational; seetools/deploy-dry-run-diff.shfor the plan.
Testing
cli/lucli/tests/specs/deploy/ extends wheels.wheelstest.system.BaseSpec. Run with:
bash tools/test-cli-local.sh
Fixtures at cli/lucli/tests/_fixtures/deploy/configs/ (minimal.yml, full.yml, with-accessories.yml, invalid/*.yml). FakeSshPool.cfc records every command for offline assertions; no sshd needed for unit tests. SshClientSpec + SshPoolSpec exercise real SSH via the fixture at cli/lucli/tests/_fixtures/deploy/sshd/ (brought up by tools/deploy-sshd-up.sh).
Reference docs
- User guides:
docs/src/working-with-wheels/deployment/(first-deploy, config-reference, accessories, secrets, hooks, migrating-from-kamal) - Per-verb CLI reference:
docs/src/command-line-tools/commands/deploy/ - Design spec:
docs/superpowers/specs/2026-04-20-wheels-deploy-kamal-port-design.md - Implementation plan:
docs/superpowers/plans/2026-04-20-wheels-deploy-kamal-port.md - Retrospective:
docs/superpowers/plans/2026-04-21-phase1-retrospective.md
Server-Sent Events (SSE) Quick Reference
// In a controller action — single event response
function notifications() {
var data = model("Notification").findAll(where="userId=#params.userId#");
renderSSE(data=SerializeJSON(data), event="notifications", id=params.lastId);
}
// Streaming multiple events (long-lived connection)
function stream() {
var writer = initSSEStream();
for (var item in items) {
sendSSEEvent(writer=writer, data=SerializeJSON(item), event="update");
}
closeSSEStream(writer=writer);
}
// Check if request is from EventSource
if (isSSERequest()) { renderSSE(data="..."); }Client-side: const es = new EventSource('/controller/notifications');
Browser Testing Quick Reference
Shipped in v4.0 across PRs #2113, #2115, #2116. Specs extend wheels.wheelstest.BrowserTest and drive a real Chromium through this.browser — a fluent DSL wrapping Playwright Java.
// vendor/wheels/tests/specs/browser/LoginBrowserSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
this.browserEngine = "chromium"; // chromium only in PR 1
function run() {
// browserDescribe() wraps describe() with beforeEach/afterEach that
// create a fresh Page per `it`. WheelsTest's BDD lifecycle only treats
// beforeAll/afterAll as class-level, so we register per-it hooks
// from inside the suite body via this helper.
browserDescribe("Login flow", () => {
it("can load a page and read its title", () => {
if (this.browserTestSkipped) return;
this.browser.visitUrl("data:text/html,<title>Hi</title><h1>x</h1>")
.assertTitleContains("Hi");
});
});
}
}Install Playwright locally before first run (~370MB download: JARs + Chromium):
wheels browser setup # downloads JARs + ChromiumThen run browser specs via the normal test suite:
bash tools/test-local.sh # skips browser specs if JARs missingImplemented DSL methods
- Navigation: visit, visitUrl, back, forward, refresh, visitRoute
- Interaction: click, press, fill, type, clear, select, check, uncheck, attach, dragAndDrop
- Keyboard: keys, pressEnter, pressTab, pressEscape
- Waiting: waitFor, waitForText, waitForUrl
- Scoping: within(selector, callback)
- Cookies: setCookie, deleteCookie, cookie, clearCookies
- Auth: loginAs, logout
- Dialogs: acceptDialog, dismissDialog, dialogMessage (Lucee-only via createDynamicProxy)
- Viewport: resize, resizeToMobile, resizeToTablet, resizeToDesktop
- Script: script (returns
page.evaluateresult), pause - Assertions (text/vis/presence): assertSee, assertDontSee, assertSeeIn, assertVisible, assertMissing, assertPresent, assertNotPresent
- Assertions (URL/title/query): assertUrlIs, assertUrlContains, assertTitleContains, assertQueryStringHas, assertQueryStringMissing, assertRouteIs
- Assertions (form): assertInputValue, assertChecked, assertHasClass
- Terminals: currentUrl, title, pageSource, text, value, screenshot
Key gotchas
##in selectors — CFML requires##to emit literal#."##email"→"#email"at runtime.clientis a Lucee reserved scope.var client = ...in a closure throws "client scope is not enabled". Usevar c = ...orvar bc = ....- Data URLs work for most tests — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl).
this.browserTestSkipped— when Playwright JARs aren't installed (fresh CI, clean machine),beforeAllsets this flag andbrowserDescribe's hooks short-circuit. Allits should checkif (this.browserTestSkipped) return;to stay green on CI.- CI runs browser tests —
pr.ymlandsnapshot.ymlinstall Playwright JARs + Chromium (cached viabrowser-manifest.jsonhash). Browser specs run as part of the normal test suite.WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007is set automatically. - Fixture routes —
/_browser/login-asand/_browser/logoutare mounted automatically in test mode. They must come before.wildcard()in routes.cfm. In the Routes UI (/wheels/routes) all/_browser/*routes appear under the Internal tab, not Application. - Dialogs are Lucee-only —
acceptDialog,dismissDialog,dialogMessageusecreateDynamicProxywhich is Lucee-specific. Specs skip gracefully on other engines.
Full reference: .ai/wheels/testing/browser-testing.md.
Reference Docs
Deeper documentation lives in .ai/ — Claude will search it automatically when needed:
.ai/wheels/cross-engine-compatibility.md— Start here for Lucee/Adobe cross-engine gotchas.ai/cfml/— CFML language reference (syntax, data types, components, control flow, best practices).ai/wheels/core-concepts/— MVC architecture, ORM mapping, routing conventions, Rails comparison.ai/wheels/models/— ORM details, associations, validations, scopes, enums, batch processing.ai/wheels/controllers/— actions, filters, rendering (JSON/views/redirects), security, SSE, parameter verification.ai/wheels/views/— layouts, partials, form helpers (including HTML5), link helpers, pagination, forms.ai/wheels/database/— migrations, queries, associations, validations, seeding.ai/wheels/configuration/— routing, environments, settings, DI container, multi-tenancy, security.ai/wheels/middleware/— pipeline structure, rate limiting, tenant resolver.ai/wheels/jobs/— background job queue, retries, priority queues.ai/wheels/mcp/— AI agent integration via the Wheels CLI's stdio MCP (setup, tool reference, auto-discovery).ai/wheels/packages/— first-party packages (sentry, hotwire, basecoat) + activation model.ai/wheels/cli/— generators (model, controller, scaffold, admin, migrations).ai/wheels/testing/— WheelsTest BDD, browser testing, browser automation patterns, onboarding harness (fresh-install simulation for cliff fixes).ai/wheels/security/— CSRF protection, HTTPS detection.ai/wheels/patterns/— authentication, CRUD, validation templates.ai/wheels/snippets/— copy-paste model + controller examples.ai/wheels/troubleshooting/— common errors, form helper errors
Commit Message Conventions
This repo uses commitlint. The canonical rules live in commitlint.config.js; this section reflects them. If the two ever disagree, the config wins.
Format
type(scope): subject — scope is optional.
- type is required.
- scope is optional and unrestricted. Pick a short noun that helps a reader skim
git log(e.g.model,cli,web/blog), or omit it entirely. There is no allowlist — pick what fits. - subject is required, must not be empty, must not be ALL-CAPS, and the full header must be ≤ 100 chars.
Valid types
feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.
Suggested scopes (not enforced)
These are common scopes used in this repo. None are required, and you can use scopes outside this list freely.
- Framework layers:
model,controller,view,router,middleware,migration,cli,test,config,di,job,mailer,plugin,sse,seed,docs - Static-site monorepo (under
web/):web,web/ui,web/landing,web/blog,web/guides,web/api,web/packages
Subject rules
- Must not be empty.
- Must not be ALL-CAPS (e.g.,
fix: FIX BUGis rejected). - Sentence-case, start-case, and pascal-case are allowed — proper nouns like
Giscus,CockroachDB,Buttondownkeep their canonical capitalization. - Header (
type(scope): subject) capped at 100 chars.
Branding
The project name is Wheels (not "CFWheels"). The rebrand happened at v3.0. Always use "Wheels" in new code, comments, commit messages, PR descriptions, and documentation.
MCP Server
Canonical surface (Wheels 4.0+): the Wheels CLI's stdio MCP server at wheels mcp wheels. Configure your AI IDE with:
{"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}}Or run wheels mcp setup to generate .mcp.json + .opencode.json automatically.
Tools are auto-discovered from cli/lucli/Module.cfc public functions, prefixed with the module name (wheels_generate, wheels_migrate, wheels_test, wheels_reload, wheels_seed, wheels_analyze, wheels_validate, wheels_routes, wheels_info, wheels_destroy, wheels_doctor, wheels_stats, wheels_notes, wheels_db, wheels_upgrade, wheels_create, wheels_deploy). CLI-only tools (mcp, d, new, console, start, stop, browser) are hidden from MCP tools/list via mcpHiddenTools().
Workflow orchestration (multi-step planning, feature development) is not a framework concern — use your preferred Claude Code plugin (Superpowers, feature-dev, etc.). The framework ships deterministic Wheels operations via MCP; the model orchestrates.
Deprecated: The in-dev-server HTTP endpoint at /wheels/mcp (routed from vendor/wheels/public/views/mcp.cfm). Emits a deprecation notice and warning log on first request. Scheduled for removal in a future release — migrate to the stdio surface. See docs/command-line-tools/commands/mcp/mcp-configuration-guide.md.
Wheels Bot
wheels-bot[bot] is a custom GitHub App that runs Claude-powered automation on issues and PRs in wheels-dev/wheels. Five stages, all opt-out via the [skip-claude] label or repo variable WHEELS_BOT_ENABLED=false. Slash-command prompts live in .claude/commands/; workflows in .github/workflows/bot-*.yml. Full docs: docs/contributing/wheels-bot.md.
| Stage | Trigger | Model | Output |
|---|---|---|---|
| Triage | issue opened/reopened | Opus | Comment classifying as bug / framework-design / other (+ confidence on bug path). Reads code with the allowlisted tools to resolve uncertainty before rating. |
| Research | bot triage emits framework-design marker |
Opus | Comment comparing Rails / Laravel / Django / Phoenix / Spring Boot / +1 and recommending a Wheels-idiomatic path (+ confidence). |
| Propose Fix | bot triage emits triage-confidence:high|medium OR research emits research-confidence:high|medium (or workflow_dispatch) |
Opus | TDD-mandatory draft PR on branch fix/bot-<issue>-<slug>. Spec-then-implementation, both required by bot-tdd-gate.yml. |
| Reviewer A | PR opened / synchronized / ready_for_review | Sonnet | Single PR review with line comments, verdict, and wheels-bot:review-a:<pr>:<sha> marker. |
| Reviewer B | Reviewer A submits a review | Sonnet | PR comment critiquing A for sycophancy, false positives, and missed issues. Loop cap = 3 rounds. |
Marker conventions (HTML comments, used for idempotency):
<!-- wheels-bot:triage:<issue> -->+<!-- wheels-bot:triage-class:<bug|framework-design|other> -->(+ optional<!-- wheels-bot:triage-confidence:high|medium -->— either fires propose-fix; low omitted)<!-- wheels-bot:research:<issue> -->(+ optional<!-- wheels-bot:research-confidence:high|medium -->— either fires propose-fix; low omitted)<!-- wheels-bot:fix:<issue> -->/<!-- wheels-bot:fix-held:<issue> --><!-- wheels-bot:review-a:<pr>:<sha> --><!-- wheels-bot:review-b:<pr>:<sha>:<round> --><!-- wheels-bot:auto-close:<issue> -->
Allow-listed scopes per stage: every bot-authored commit must conform to the commitlint.config.js allowlist (see § Commit Message Conventions). The bot's prompt (.claude/commands/_shared-rails.md) re-states the allowlist verbatim.
Kill switch: flip the repo variable WHEELS_BOT_ENABLED to false to halt every bot workflow without code changes. Add the [skip-claude] label (or [skip-claude] in the title) to halt activity on a single issue/PR.
Auto-fire safety net: the bot is permitted to chain stages (triage → research → propose-fix), and handoff fires on *-confidence:high OR *-confidence:medium. Low stays manual. Sensitive areas (security, middleware, migrations, deploy, DI, cross-engine) are caught by the propose-fix prompt's own step-4 safety net, which posts a fix-held marker instead of opening a PR. Reviewer A and B then critique whatever propose-fix produces, escalating to the Senior Advisor on deadlock. All bot PRs land as --draft and require a human approving review on develop.
There was a problem hiding this comment.
Wheels Bot - Reviewer A
TL;DR: This PR correctly adds a helper to identify binary columns and exempts them from the scalar-column type guard in the model property setter. The production-code change is sound. However, the test spec has two issues - including a cross-engine correctness problem that can produce a false positive on BoxLang and Lucee 6, the engines this fix targets.
CORRECTNESS
Issue 1 - false-positive risk from local.bytes in nested closure
binaryPropertyAssignmentSpec.cfc lines 17-22 assign local.bytes in the outer it-closure, then reference local.bytes inside the inner closure passed to expect().
CLAUDE.md Closure gotcha: CFML closures cannot access outer local vars - use shared structs.
On engines where closures do not capture the outer local scope (BoxLang, some Lucee 6 configurations), local.bytes is undefined in the inner lambda. A generic Expression-type exception is thrown, not Wheels.PropertyIsIncorrectType.
The notThrows implementation in Assertion.cfc lines 692-694 silently returns when the caught exception type does not match the expected type. So notToThrow(type=Wheels.PropertyIsIncorrectType) silently passes on any other exception type. The model code never runs - a false positive on the same engines this fix is for.
Fix: inline the byte-array literal directly inside the inner closure body to remove the cross-closure local dependency. Tests 3 and 4 in the same spec correctly do this already.
Issue 2 - assertion does not verify the value was preserved
binaryPropertyAssignmentSpec.cfc line 25 calls expect(local.photo).toHaveKey(fileData). The test title says preserves the binary value on the model when assigned via new() but toHaveKey only confirms the key exists - null or any default satisfies it. Use toBe on local.photo.fileData to verify the actual stored value matches the input bytes.
CROSS-ENGINE
The production code change is cross-engine safe. The new helper uses only struct key lookups and is declared public (correct for mixin CFCs per CLAUDE.md - integrateComponents only copies public methods). No Lucee/Adobe/BoxLang-specific APIs are used.
The local-scope gotcha in the test is the cross-engine concern described above.
CONVENTIONS
The JSDoc block on the new helper in properties.cfc lines 606-612 is a multi-line comment block. CLAUDE.md advises one short line max for internal helpers. Minor nit.
TESTS
Happy-path and regression paths are present. The false-positive risk in test 1 is the main gap. Tests 3 and 4 are correct and unaffected by the scope gotcha.
DOCS
CHANGELOG Unreleased/Fixed entry is present and correctly describes the engine-specific behaviour difference. No user-facing guide changes needed for an internal model-setter fix.
COMMITS
fix(model): exempt binary columns from scalar-column type guard in setProperty - valid type, subject within 100 chars, not ALL-CAPS. Conforms to commitlint.config.js.
Wheels Bot — Reviewer B (round 1)Reviewer A's review (4288972360) is a bot malfunction: the review body is the complete CLAUDE.md documentation file (~56 KB) rather than any analysis of this PR, and there are zero line comments. There is nothing to critique in the normal sycophancy / false-positive / verdict-alignment sense. Because A produced no findings, I performed a direct diff scan and am reporting what A should have caught. SycophancyNot applicable — A produced no review content to evaluate for sycophancy. False positivesNot applicable — A made no claims to evaluate. Missed issuesCritical: test spec will fail on SQLite.
SQLite is the default CI database (used on every engine in the compat matrix), so this is a blocking failure. The fix itself is correct; the test's model/column setup does not reflect the fix's intent on SQLite. Options:
Minor: redundant null-guards inside The function re-checks Verdict alignmentA submitted no verdict. The review state is ConvergenceNot aligned. A has not reviewed this PR -- its submission is a context-dump error (review body = CLAUDE.md). A needs to re-run its review on this SHA and address the SQLite test failure described above. Until A produces a real review there is nothing to converge on. |
Wheels Bot — Reviewer B (round 1)A's review is substantively correct. Both findings are real bugs in the test spec, and the false-positive analysis for Issue 1 is verified against SycophancyNone detected. A identified concrete issues and did not approve. False positivesNone detected. Verified each claim against the diff:
Missed issuesOne minor nit A did not flag: the two new inline comment lines added to Verdict alignmentA posted as COMMENTED rather than CHANGES_REQUESTED despite identifying a cross-engine false-positive and a weak assertion. The verdict should be CHANGES_REQUESTED; posting as COMMENTED understates the severity. The findings themselves are correct and actionable, so this is a form issue with A's verdict, not a content issue. ConvergenceAligned on substance: the test spec has two real issues that must be fixed (closure-scope false positive in test 1; weak assertion in test 2) plus the minor multi-line comment nit. A's recommendation to fix these is correct. Changes are needed before the PR can be approved. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR delivers a clean, targeted fix — exempting binary columns from the scalar-column type guard in $setProperty unblocks array-shaped byte content on BoxLang and Lucee 6, which is exactly what #2660 asks for. The core change in properties.cfc is correct. However, binaryPropertyAssignmentSpec.cfc has two issues that will cause test failures in CI: (1) tests 1 and 2 will fail on SQLite because filedata is declared as TEXT in populate.cfm, which the SQLite adapter maps to cf_sql_varchar → validationtype == "string" — not "binary" — so $propertyIsBinaryColumn returns false on SQLite and the guard still fires; and (2) test 1's inner expect() lambda reads local.bytes from the outer it() closure, which CFML engines do not capture, making the assertion potentially vacuous. Changes needed on correctness and test quality grounds.
Correctness
vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc lines 17–22 and 24–28 — SQLite test failure
filedata in populate.cfm is declared as TEXT for SQLite (line 72: local.binaryColumnType = "TEXT"). SQLiteModel.cfc's $getType maps text → cf_sql_varchar, and Base.cfc::$getValidationType maps cf_sql_varchar to "string" (the default branch, line 412). So $propertyIsBinaryColumn("filedata") returns false on SQLite — the guard still applies — and both it cases that call g.model("photo").new(filename = "test.png", fileData = local.bytes) will throw Wheels.PropertyIsIncorrectType, failing the spec on every SQLite CI run.
The existing crudSpec.cfc already handles this difference: lines 17–19 convert binary data to base64 before assignment on SQLite:
if (application.wheels.adapterName eq 'SQLiteModel') {
binaryData = toBase64(binaryData)
}The new spec has no equivalent guard. Suggested fix — add an adapter check at the top of run(), following the _isCockroachDB pattern from propertiesSpec.cfc, deleteSpec.cfc, etc.:
function run() {
g = application.wo
var _isSQLite = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "SQLiteModel";
describe("Binary column property assignment", () => {
it("accepts array-shaped binary data without tripping the scalar-column guard", () => {
if (_isSQLite) return; // SQLite stores binary columns as TEXT/base64; array-bypass does not apply
...
})
it("preserves the binary value on the model when assigned via new()", () => {
if (_isSQLite) return;
...
})
})
}(An alternative would be changing local.binaryColumnType = "TEXT" to "BLOB" in populate.cfm for SQLite, since SQLiteModel.cfc already maps blob → cf_sql_blob → "binary". But that is a broader test-schema change that would need to reconcile with crudSpec.cfc's base64 logic and is out of scope for this PR.)
vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc lines 18–21 — local.bytes inaccessible in inner closure
it("accepts array-shaped binary data without tripping the scalar-column guard", () => {
local.bytes = [137, 80, 78, 71, 13, 10, 26, 10]
expect(() => {
g.model("photo").new(filename = "test.png", fileData = local.bytes) // local.bytes not captured
}).notToThrow(type = "Wheels.PropertyIsIncorrectType")
})local.bytes is set in the outer it() closure and referenced inside the inner expect() lambda. CLAUDE.md §Testing is explicit: "CFML closures can't access outer local vars — use shared structs (var result = {count: 0})." The inner lambda's local scope is independent of the outer closure's; on Lucee and Adobe CF, local.bytes inside the lambda is undefined. If fileData receives null/undefined rather than the array, IsArray(null) is false so the guard never fires — the test passes vacuously without exercising the fix.
Suggested fix — use a shared struct so the inner closure can reach the value through variables capture:
it("accepts array-shaped binary data without tripping the scalar-column guard", () => {
if (_isSQLite) return;
var ctx = { bytes: [137, 80, 78, 71, 13, 10, 26, 10] }
expect(() => {
g.model("photo").new(filename = "test.png", fileData = ctx.bytes)
}).notToThrow(type = "Wheels.PropertyIsIncorrectType")
})Tests 2–4 are unaffected: test 2 uses local.bytes and local.photo in the same closure body (no nested lambda), and tests 3–4 inline their arguments without referencing outer locals.
Tests
The regression tests for the non-binary guard (tests 3 and 4) are well-constructed and cover the #2412 behavior correctly. Once the SQLite guard and closure fix are in place, the spec will be reliable across the full CI matrix.
Docs
Both documentation changes are accurate and well-placed. The per-engine table in .ai/wheels/cross-engine-compatibility.md clearly captures the IsArray/IsBinary surface differences. The <Aside> note in the guides page correctly describes the JDBC-layer compatibility. No issues.
Commits
Both commits conform to commitlint.config.js:
fix(model): exempt binary columns from scalar-column type guard in $setProperty— valid type, scope, subjectdocs: document binary column cross-engine behavior and blob-storage upload pattern— valid type, subject
Wheels Bot — Reviewer B (round 3)A's review is substantively sound on its two core findings, both of which are verified. However, A missed a real test-correctness issue in test 2 and a comment-style violation that was flagged in a prior B round, and A's verdict understates the severity. Not aligned yet. SycophancyNone detected. A leads with a clear TL;DR that changes are required and does not give a vacuous LGTM. False positivesNone detected. Both of A's claims hold:
A's suggested Missed issuesTest 2 — weak assertion not flagged. A notes correctly that test 2 has no nested-lambda scope issue, but then stops there. The assertion itself is wrong: // binaryPropertyAssignmentSpec.cfc line 25
expect(local.photo).toHaveKey("fileData")
expect(local.photo.fileData).toBe(local.bytes)This was flagged in a prior B round on this SHA. A's review does not address it. Multi-line comment blocks in the production change.
Verdict alignmentA submitted as ConvergenceNot aligned. A must address two outstanding items before we can converge:
Once those are fixed, the PR is review-clean on this SHA (the production code in |
- vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc:
- Add _isSQLite guard to skip tests 1 and 2 on SQLite, where
populate.cfm declares filedata as TEXT and the adapter maps it
to cf_sql_varchar / validationtype "string" — the binary-column
carve-out does not apply, so the guard still throws.
- Replace local.bytes with `var ctx = { bytes: [...] }` so the
inner expect() lambda captures the value through bare-name
scope walking instead of relying on outer-local closure capture
(CLAUDE.md closure gotcha).
- Replace `expect(local.photo).toHaveKey("fileData")` with
`expect(photo.fileData).toBe(ctx.bytes)` so the assertion
actually verifies the binary value was preserved.
- vendor/wheels/model/properties.cfc:
- Collapse the JSDoc on $propertyIsBinaryColumn and the inline
comment block in $setProperty to single short lines per
CLAUDE.md "one short line max" convention.
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):
Local test runThe local sandbox running this address-review stage does not have the The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
Wheels Bot — Reviewer A response (round 3)
After verifying the actual PR diff directly, both of B's round-3 "missed issues" describe code that does not exist at HEAD edc5cffe. The test spec already contains the _isSQLite skip guard, uses the var ctx = { bytes: [...] } shared-struct pattern (not local.bytes), and asserts with expect(photo.fileData).toBe(ctx.bytes) (not toHaveKey). Likewise, $propertyIsBinaryColumn has a single-line comment, not a 6-line JSDoc. The only valid observation is that the $setProperty condition block now has two consecutive // lines — one pre-existing, one new — which is a minor CLAUDE.md style concern. Critically, my own prior review described problems that do not appear in the current diff; B's round-3 critique reproduced those stale descriptions rather than re-reading the source. The production fix is correct and the test spec handles all cross-engine concerns. Updated verdict: approve.
Conceded points
- Two consecutive comment lines in
$setProperty(properties.cfc): the newly added// Binary columns are exempt — BoxLang / Lucee 6 surface bytes as a CFML array. See #2660.sits directly beneath the pre-existing// Scoped to real DB columns so loaded hasMany arrays and control-param leakage still pass through. See #2412., producing two back-to-back//lines in the same condition block. CLAUDE.md is explicit: "one short line max." The new line could be folded into a single combined comment or omitted in favour of theSee #2660reference on the existing line. Minor; not blocking on its own.
Defended points
-
Test 2 assertion — reject B's claim: B states test 2 reads
expect(local.photo).toHaveKey("fileData")at line 25. The actual diff showsexpect(photo.fileData).toBe(ctx.bytes)(binaryPropertyAssignmentSpec.cfc, line 30 of the new file).toHaveKeydoes not appear anywhere in the spec. B's description does not match the file at current HEAD and should not be treated as a blocking finding. -
$propertyIsBinaryColumncomment style — reject B's claim: B asserts a "6-line JSDoc on lines 607-612." The diff shows exactly one comment line above the function:// Returns true when the named property maps to a binary DB column. See #2660.(properties.cfc). No multi-line docstring exists in the current file at HEAD. B's description does not match the source. -
My own prior SQLite and closure-scope findings — self-correction: My review on this SHA described test 1 as using
local.bytesin a nested lambda and tests 1–2 as lacking a SQLite guard. Neither claim holds against the actual diff. The file usesvar ctx = { bytes: [...] }withctx.bytesthroughout (the recommended shared-struct pattern per CLAUDE.md), and both tests carryif (_isSQLite) return;guards. My prior findings were based on an incorrect reading of the source; B confirmed them from my descriptions rather than the diff, which propagated the error into round 3.
Updated verdict
Approve — the production fix is sound, the test spec correctly handles all cross-engine and database concerns, and the only outstanding observation (two consecutive comment lines in $setProperty) is a minor style nit that does not block merge; it differs from my initial COMMENTED state because re-reading the actual diff resolves the correctness concerns I raised against code that is already correct.
Wheels Bot — Reviewer B (round 1)A's round-3 response checks out against the actual diff. Every claim A makes about the current state of the file is verified, A's self-correction is accurate, and the approval verdict is appropriate. One factual error in A's response (wrong SHA reference) and one minor code nit that A conceded but did not explicitly recommend fixing. SycophancyNone detected. A leads with substantive verification, self-corrects prior incorrect claims, and concedes one real style nit. False positivesNone detected. Verified each of A's claims against the diff on HEAD
Missed issuesFactual error: wrong SHA cited as HEAD. A's response opens: "does not exist at HEAD Two consecutive comment lines in
// Scoped to real DB columns so loaded hasMany arrays and control-param leakage still pass through. See #2412.
// Binary columns are exempt — BoxLang / Lucee 6 surface bytes as a CFML array. See #2660.A acknowledges this in the "Conceded points" section but frames it as a minor observation rather than a blocking finding. CLAUDE.md is explicit: "one short line max." The new comment is genuinely useful (the why is non-obvious), but the pre-existing comment already occupies the one permitted slot. The fix is mechanical: drop the second line and append Redundant null-guards in The first two Verdict alignmentA's review body says "approve" but the GitHub review state is ConvergenceAligned. A's approval verdict is correct for this SHA. The production fix in Joint recommendation: approve. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: The production-code fix is correct and well-implemented: $propertyIsBinaryColumn is cleanly guarded, the validationtype == "binary" check correctly covers every binary SQL type the framework maps, and the docs updates (cross-engine compat doc, CHANGELOG, guides MDX) are accurate and follow established patterns. However, the test guard for SQLite uses the wrong adapter name string ("SQLiteModel" vs the actual return value "SQLite"), which means _isSQLite is always false, the guard never fires, and tests 1 and 2 will fail on every SQLite run. This is a correctness issue requiring a fix before merge.
Correctness
vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc — line 7: wrong adapter name string
var _isSQLite = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "SQLiteModel";The migrator adapter's adapterName() method returns the short name "SQLite", not "SQLiteModel". Confirmed from the source:
// vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc line 25
public string function adapterName() {
return "SQLite";
}Every other migrator adapter follows the same convention: MySQLMigrator returns "MySQL", PostgreSQLMigrator returns "PostgreSQL", H2Migrator returns "H2". The existing specs in the same directory use this pattern correctly: lockingSpec.cfc, deleteSpec.cfc, sqlSpec.cfc, and crudSpec.cfc (lines 6 in each) all compare against the short name "CockroachDB" when detecting CockroachDB.
The consequence is that _isSQLite is always false on every engine and database. Tests 1 and 2 run unconditionally, including on SQLite. On SQLite, populate.cfm declares c_o_r_e_photos.filedata as TEXT (binaryColumnType = "TEXT" at line 72), which the adapter maps to validationtype == "string". $propertyIsBinaryColumn returns false, the scalar-column guard fires, and both .notToThrow assertions fail.
Fix: change "SQLiteModel" to "SQLite" on line 7.
Alternatively, use application state directly (as crudSpec.cfc line 17 and propertiesSpec.cfc lines 551/573 do): application.wheels.adapterName eq "SQLiteModel". That form uses the model-adapter name which does include the Model suffix. Either approach is correct; the migrator-adapter path just needs the short name.
Tests (observation)
var _isSQLite scope vs unscoped g
g = application.wo (line 5, no var) lands in variables scope and is accessible from all closures on all engines. var _isSQLite (line 7) lands in local scope of run(). CFML closures on Adobe CF only capture the declaring component's variables scope — not the enclosing function's local scope — so _isSQLite could be unresolvable inside the it() lambdas on Adobe CF (CLAUDE.md § "Closure gotcha").
In practice, lockingSpec.cfc, deleteSpec.cfc, sqlSpec.cfc, and crudSpec.cfc all use var _is<Adapter> = ... in run() and access the flag inside it() closures, and those pass on Adobe CF in CI. So this pattern evidently works, likely because WheelsTest normalizes scope. Flagging as an observation, not a blocker — but aligning with the unscoped g = ... convention would be the safest long-term choice.
Commits
All three commits conform to commitlint.config.js: valid type (fix, docs, fix), subject within 100 chars, not ALL-CAPS. No issues.
Security
No concerns. The docs example correctly uses cffile.serverFile (the server-assigned name) rather than cffile.clientFile (user-supplied, potentially unsafe), and nameconflict="makeunique" prevents overwrite races.
|
@claude can you fix the merge conflict |
…signmentSpec SQLiteMigrator.adapterName() returns "SQLite", not "SQLiteModel" — the mistyped string left the two binary-shape tests running on SQLite, where populate.cfm declares filedata as TEXT (cf_sql_varchar / validationtype "string"). The binary-column carve-out doesn't apply to a TEXT column, so the scalar-column guard correctly throws and the tests fail.
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR adds a targeted binary-column exemption to the scalar-column type guard in $setProperty, unblocking file-upload-into-blob flows on BoxLang and Lucee 6 where FileReadBinary() returns a CFML array rather than a byte[]. The fix logic is correct, public-access is used appropriately for the mixin helper, and the cross-engine doc and user-facing guide are updated. A few minor test and commit concerns are worth a follow-up pass before merge; none are blockers for a --comment verdict.
Correctness
Fix logic looks sound. The new guard in $setProperty adds && !$propertyIsBinaryColumn(arguments.property) to the existing condition, meaning the throw only fires when the column is not binary. The JDBC driver accepts the array shape for blob/bytea/varbinary columns on all supported engines, so skipping the throw here is the right move.
$propertyIsBinaryColumn defensive checks are thorough. The function (properties.cfc:606-615) guards all three struct lookups before comparing validationtype, so it returns false safely when property metadata is absent — no NPE risk.
Minor: CLOB columns are also exempted. CF_SQL_CLOB maps to validationtype == "binary" in databaseAdapters/Base.cfc:396, so any CLOB column will also bypass the guard. CLOB is character data, not raw bytes, and passing an integer array into a CLOB column is not a documented use case. This is a pre-existing mapping, not introduced by this PR, but the PR's fix makes it observable. Worth a comment or TODO; not a blocker.
Tests
var ctx inside a nested expect() lambda — potential engine-specific scope capture.
binaryPropertyAssignmentSpec.cfc:21-25:
var ctx = { bytes: [137, 80, 78, 71, 13, 10, 26, 10] }
expect(() => {
g.model("photo").new(filename = "test.png", fileData = ctx.bytes)
}).notToThrow(type = "Wheels.PropertyIsIncorrectType")The round-1 fix commit says this was changed from local.bytes to var ctx = { bytes: [...] } to address the CLAUDE.md closure-local-var gotcha. The intent was correct, but var ctx still puts ctx in the local scope of the it() closure. When the inner lambda () => { ctx.bytes } runs inside notToThrow(), it resolves ctx from the outer closure's local scope — not from the captured variables scope. CLAUDE.md calls this out directly: "CFML closures can't access outer local vars — use shared structs."
The established var _isCockroachDB pattern in lockingSpec.cfc does the same and works in practice because notToThrow() invokes the lambda synchronously while the outer frame is still active. This is still relying on implementation-defined behavior, and will silently break if WheelsTest ever defers lambda execution. The safest fix is to inline the array literal directly:
expect(() => {
g.model("photo").new(filename = "test.png", fileData = [137, 80, 78, 71, 13, 10, 26, 10])
}).notToThrow(type = "Wheels.PropertyIsIncorrectType")The second it() block (binaryPropertyAssignmentSpec.cfc:28-31) uses ctx directly in the same closure body (not passed to a nested lambda), so the risk there is lower.
Missing save() path. All four spec cases call model("photo").new(), which exercises the property setter but never touches the database. A test that calls save() or create() would verify that the array shape passes through to JDBC binding. Flagging for completeness — understood that the local test runner was unavailable.
SQLite skip is correct and well-documented. populate.cfm:72 sets binaryColumnType = "TEXT" on SQLite, giving validationtype == "string", so the carve-out does not apply. The comment on line 6-7 explains this clearly. ✓
Commits
Third commit subject is opaque.
fix(model): address Reviewer A/B consensus findings (round 1)
The commit body lists the specific changes, but the subject reads like process metadata rather than a codebase description. Future readers of git log --oneline will not know what changed from the subject alone. Suggested rephrase: fix(model): inline array literals in spec lambdas; collapse $propertyIsBinaryColumn comment.
DCO sign-off present on both bot-authored commits. ✓
Docs
CHANGELOG entry ✓ (thorough and accurate). Cross-engine compatibility section added ✓ (the engine table in .ai/wheels/cross-engine-compatibility.md is a useful reference). User-facing guide updated ✓ (v4-0-1-snapshot/.../file-uploads-and-downloads.mdx). $propertyIsBinaryColumn uses public access with $-prefix naming ✓ per CLAUDE.md mixin rules.
Wheels Bot -- Reviewer B (round 1)A's review is mostly sound on the production code and the SQLite skip guard, but contains one false positive on the closure-scope concern, and A's COMMENTED verdict should be APPROVED given A's own assessment that nothing is blocking. The only new commit on this SHA is a one-line adapter-name correction that is demonstrably correct. SycophancyNone detected. A identifies only observations it explicitly calls non-blocking rather than rubber-stamping an approve. False positivesvar ctx scope concern is a false positive. A claims var ctx puts ctx in the local scope of the it() closure and that referencing it inside the inner expect() lambda is "implementation-defined behavior" that will break if WheelsTest defers execution. This contradicts CLAUDE.md's own recommended pattern: "CFML closures can't access outer local vars -- use shared structs (var result = {count: 0})". The example var result = {count: 0} creates result in the local scope -- exactly what var ctx = { bytes: [...] } does here. A is implying CLAUDE.md's own recommendation is also broken, which it is not. More practically: notToThrow() invokes the lambda synchronously, the outer it() frame is still on the stack, and ctx resolves through the live scope chain identically on Lucee, Adobe, and BoxLang. Deferred execution is not a concern here. Prior B on SHA 0fdbf2e verified and approved this exact pattern as "the safe path per CLAUDE.md." A is re-raising a concern that was already evaluated and closed. A's suggested fix (inlining the array literal) is a style preference, not a correctness fix. Missed issuesA's marker SHA is wrong. The review body ends with the marker for SHA ed678de but the review was submitted on commit_id f1ead7b (confirmed via the API). ed678de is the merge-base commit on this branch, not the HEAD. This is the same category of marker error prior B noted in round 1 on 0fdbf2e. No code impact; minor. A does not explicitly validate the adapter-name fix. The sole change in the new commit (f1ead7b) is correcting adapterName() == "SQLiteModel" to adapterName() == "SQLite" in the spec. A confirms the _isSQLite guard is present and correct without noting what the fix actually changed. Verified independently: vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc:25 returns "SQLite", not "SQLiteModel". The fix is correct. CLOB observation is already documented by the PR. A notes that CF_SQL_CLOB maps to validationtype == "binary" in Base.cfc:396, which is accurate and pre-existing. However, the PR's own .ai/wheels/cross-engine-compatibility.md diff already explicitly lists clob among the binary column types: "blob, longblob, bytea, varbinary, clob." A treats this as an unremarked gap when the PR already surfaces it. Not a missed issue. Verdict alignmentA submitted COMMENTED despite stating "none are blockers for a --comment verdict" -- an unusual framing that concedes nothing requires changes while still declining to approve. The previous B converged-approve on 0fdbf2e, which had all the substantive code. The only delta on f1ead7b is a one-line SQLite adapter name correction that is demonstrably right. APPROVED is the appropriate verdict. ConvergenceAligned. The production fix in properties.cfc is correct, the test spec correctly handles SQLite, the shared-struct closure pattern is the CLAUDE.md-recommended approach, and all prior findings have been addressed. The CLOB mapping is pre-existing, documented by the PR, and not a blocker. A's own analysis supports approval. Joint recommendation: approve. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR correctly fixes the scalar-column type guard in $setProperty to allow array-shaped binary values from BoxLang / Lucee 6 to reach the JDBC layer. The core logic, test structure, changelog, and docs are all sound. One correctness issue needs addressing before merge: the binary-column exemption is written too broadly — it silently lets struct values through for binary columns as an unintended side effect, bypassing the protection that #2412 added for those cases.
Correctness
Over-broad exemption allows struct values through for binary columns
File: vendor/wheels/model/properties.cfc, lines 580–584
} else if (
(IsStruct(arguments.value) || IsArray(arguments.value))
&& StructKeyExists(variables.wheels.class, "properties")
&& StructKeyExists(variables.wheels.class.properties, arguments.property)
&& !$propertyIsBinaryColumn(arguments.property)
) {!$propertyIsBinaryColumn(arguments.property) evaluates to false for any binary column, collapsing the entire condition to false regardless of whether the value is an array or a struct. That means a struct assigned to a blob/bytea column — e.g. form data that arrived in an unexpected nested shape, exactly the class of bug #2412 was protecting against — silently falls through to this[arguments.property] = arguments.value and reaches the JDBC layer, where it produces an opaque Java-level exception instead of the friendly Wheels.PropertyIsIncorrectType message with actionable extendedInfo.
The exemption should apply only when the value is an array and the column is binary. Change the last condition to:
&& !(IsArray(arguments.value) && $propertyIsBinaryColumn(arguments.property))Full corrected guard:
} else if (
(IsStruct(arguments.value) || IsArray(arguments.value))
&& StructKeyExists(variables.wheels.class, "properties")
&& StructKeyExists(variables.wheels.class.properties, arguments.property)
&& !(IsArray(arguments.value) && $propertyIsBinaryColumn(arguments.property))
) {This preserves the original struct-guard for all columns (including binary ones) and only exempts array values on binary columns — the narrowest semantically correct change.
Tests
Missing test: struct-to-binary-column should still throw
File: vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc, after line 44
The spec has four cases. Regression cases 3 and 4 confirm that non-binary columns still reject arrays and structs. But with the current over-broad guard, a struct bound to a binary column also passes through silently — and there is no test to catch that regression. Adding a fifth case would make the narrower guard's behavior machine-verifiable:
it("still rejects struct values bound to binary columns (regression for ##2412)", () => {
if (_isSQLite) return;
expect(() => {
g.model("photo").new(filename = "ok", fileData = {nested = "should not work"})
}).toThrow(type = "Wheels.PropertyIsIncorrectType")
})Docs
Nit — CLOB listed alongside byte-level binary types
File: .ai/wheels/cross-engine-compatibility.md, new "Binary Data Representation" section
Binary columns (
blob,longblob,bytea,varbinary,clob) are now exempt from the guard
CLOBs are character large objects; they store text, not byte arrays. Listing clob alongside blob/bytea conflates two different storage classes. The exemption exists because $getValidationType internally maps CF_SQL_CLOB to "binary" — that grouping is fine internally — but presenting CLOBs as a binary type in the developer-facing doc may mislead readers choosing column types for file content. Consider dropping clob from the parenthetical list, or adding a clarifying note on the internal mapping.
Commits
All five commits use valid types (fix, docs, chore), valid scopes, subjects under 100 chars, not ALL-CAPS. Clean.
Security
No concerns.
Wheels Bot — Reviewer B (round 2)A's review is correct. The correctness finding is verified against the actual code and is a real bug. I am aligned with A that changes are needed before this PR can merge. SycophancyNone detected. A identifies a concrete code defect with a precise fix and does not approve. False positivesNone detected. Verified A's claim against A's test gap is also correct. The four existing test cases cover array-accept on binary, value-preservation on binary, array-reject on non-binary, and struct-reject on non-binary. There is no case for struct-reject on binary — the regression this PR inadvertently introduced. A's proposed fifth The CLOB docs nit is valid. Missed issuesNone. A's review is complete and actionable for this SHA. Verdict alignmentA submitted as ConvergenceAligned. A's finding is correct and I agree changes are required. The two items that must be addressed:
The CLOB docs nit is minor — a parenthetical note or removal, not a blocking concern. Everything else in the PR (production logic, SQLite skip guard, closure-scope pattern, value-preservation assertion, CHANGELOG, guide docs) was verified correct in the prior convergence cycle. |
Extends the scalar-column type guard in
$setProperty(vendor/wheels/model/properties.cfc) to skip its array/struct throw when the target property maps to a binary database column. This unblocks file-upload-into-blob-column flows on BoxLang and Lucee 6, which surface byte content as a CFML array rather than abyte[].Fixes #2660.
Why this fix
The triage comment (link) pointed at
properties.cfc:580-590and offered two fix shapes: (a) engine-adapter binary normalization or (b) a binary-column carve-out in the guard. (b) is the cheaper of the two and is sufficient to unblock the failing fixtures — the underlying JDBC driver already accepts the engine-native byte representation forcf_sql_blob/cf_sql_varbinaryparameters, so the array shape just needs to reach the bind layer. (a) remains available if a future engine surfaces a binary type the JDBC driver mis-binds; the helper introduced here gives a single hook to graduate to engine-level normalization without re-opening the guard.What changed
vendor/wheels/model/properties.cfc— adds$propertyIsBinaryColumn()and extends the guard at$setProperty()to exempt binary columns (matched viavalidationtype == "binary", whichBase.cfc::$getValidationType()already maps from every binarycf_sql_*type).vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc— new spec covering: array-shaped binary passes through; the binary value is preserved on the model; non-binary scalar columns still reject array AND struct values (regression coverage for Intermittent 'Can't cast Complex Object Type Struct to String' on first POST /signup after cold reload #2412).CHANGELOG.md—[Unreleased] > Fixedentry.Local test run
The local sandbox running this propose-fix stage did not have the
wheelsCLI installed, sobash tools/test-local.sh modelcould not start a Lucee server and exited before reaching the test runner. The spec was authored to fail at the exactThrow(type="Wheels.PropertyIsIncorrectType", ...)site in$setProperty(confirmed by reading the source path:new(fileData = [bytes])→$setProperties→$setProperty→ IsArray branch → throw). After the patch, the new exemption short-circuits the throw and the value falls through to the plainthis[property] = valueassignment. CI's compat-matrix is the authoritative validation here and will run the spec on every engine × every DB combination this issue references.Checklist
vendor/wheels/tests/specs/model/binaryPropertyAssignmentSpec.cfc(failing without the fix, passing with it via code-path inspection; CI matrix is the binding signal).bot-update-docs.yml.bot-update-docs.yml.bot-update-docs.yml.