Repository navigation
Description
Summary
Calling model().create(), .update(), or .deleteAll() inside a migration's up() or down() causes the inserted/updated row to be silently rolled back while related rows inserted in the same closure (via other code paths, e.g., raw queryExecute, addRecord(), or operations on a different model) persist as orphans referencing the rolled-back row's nonexistent ID.
Root cause: the Wheels Migrator (vendor/wheels/Migrator.cfc:98-118) already wraps each up()/down() call in its own transaction {} block. Wheels Model's default transaction = "commit" mode opens a NESTED transaction that immediately commits — at the JDBC level, on SQL Server this nested commit appears to mark a savepoint but doesn't actually release the row, and then the outer transaction's eventual transaction action="commit" doesn't see the nested-committed row to keep. The net effect for the user is that Model.create() "succeeds" (returns the object, validations pass, no errors) but no row appears in the database. Related rows inserted via paths that don't open a nested transaction (raw queryExecute, addRecord) commit normally with the outer transaction.
Repro
// app/migrator/migrations/20260521160000_seed_datapai_system_roles.cfc
component extends="wheels.migrator.Migration" hint="seeds five system roles with their permission assignments" {
function up() {
// Inside the migrator-provided outer transaction.
var Role = model("DataPaiRole");
var RolePerm = model("DataPaiRolePermission");
// This call APPEARS to succeed — no errors, no validation failures.
var role = Role.create(name="DataPaiSuperAdmin", description="bootstrap role", isSystem=true);
// The role's auto-increment ID is populated (e.g., role.id == 115).
// We can read it here.
// Insert role-permission assignments using that ID via raw SQL.
queryExecute(
"INSERT INTO datapai_role_permissions (datapai_role_id, datapai_permission_id, createdAt, updatedAt) VALUES (?, ?, GETDATE(), GETDATE())",
[
{value: role.id, cfsqltype: "cf_sql_integer"},
{value: 1, cfsqltype: "cf_sql_integer"}
],
{datasource: application.wheels.dataSourceName}
);
}
}After wheels migrate latest:
SELECT * FROM datapai_roles WHERE name = 'DataPaiSuperAdmin';
-- 0 rows. The create() was silently rolled back.
SELECT * FROM datapai_role_permissions WHERE datapai_role_id = 115;
-- 1 row. The raw INSERT committed via the outer transaction.
-- It now references role ID 115 which does NOT exist — an orphan FK.The IDENTITY value 115 IS consumed (the next inserted row gets 116), confirming the INSERT ran and the row was alive at some point during the migration before being rolled back.
Impact
This is the most insidious failure mode we hit during Phase 0 of a real production application build. We didn't notice it during initial run of the migration — wheels migrate latest printed no errors, the migration was marked migrated in wheels_migrator_versions, and the migration's up() body completed without throwing. The bug only surfaced when downstream code that depended on those roles (a session-loader query joining datapai_roles against datapai_user_roles) returned zero results.
Every CFWheels engineer doing seed-style migrations is at risk. The plan documentation pattern (seed via Model.create() inside up()) is intuitive and the code APPEARS to work in isolation, so this lands silently and shows up later as data-integrity bugs.
We worked around it by rewriting the seed migration to use raw queryExecute() + the migrator's built-in addRecord() / updateRecord() / removeRecord() helpers exclusively. See paiindustries/titan PR #3337 commit ff15bb2ae for the working pattern.
Suggested fixes (any of)
-
Detect the outer transaction and short-circuit Model's transaction handling. When
model("X").create()is invoked inside a context wheretransactionStatus()(or equivalent CFML reflection) shows an active transaction, skip the model'stransaction="commit"wrapper and let the outer transaction handle commit/rollback. The model's transaction behavior is a feature for ad-hoc app-code use, not for batch contexts like migrations. -
Print a warning when
Model.create()/.update()/.deleteAll()is called from inside awheels.migrator.Migrationsubclass. Use stack-frame inspection (callStackGet()) to detect the migrator context and emit a one-line warning recommendingaddRecord()or rawqueryExecute()instead. -
Document the limitation prominently in
guides.wheels.dev/v4-0-0/migrations/— at the top of the "Writing migrations" page, with a comparison table:Operation OK inside migration? Alternative model().create()❌ silently rolls back addRecord(table=..., col=val, ...)model().update()❌ updateRecord(table=..., where=..., col=val, ...)model().deleteAll()❌ removeRecord(table=..., where=...)queryExecute()✅ — addRecord()/updateRecord()/removeRecord()✅ — -
Long-term: align Model's transaction mode with CFML's nested-transaction semantics on each adapter. SQL Server, MySQL, Oracle, and PostgreSQL all handle nested transactions differently. The current Wheels Model assumption that
transaction="commit"is always safe doesn't hold inside an explicit outer transaction.
Repo / version
- Wheels Core:
4.0.0-SNAPSHOT+1779 - File:
vendor/wheels/Migrator.cfc:98-118(outer transaction wrapper) - Adapter: MSSQL (MicrosoftSQLServerMigrator.cfc) — but I'd expect the same on other adapters whose JDBC drivers don't implement savepoint-based nested transactions.
Where found
Surfaced building the DataPAI portal seed migrations in Titan (Phase 0). The first seed migration (20260521150000_seed_datapai_permissions.cfc) hit it for the permissions table, and the second (20260521160000_seed_datapai_system_roles.cfc) hit it harder because it had a model-create-then-FK-insert pattern that left orphan join rows. Both migrations were rewritten to use raw SQL + migrator helpers; the rewrites are committed and tagged at datapai-phase-0-complete.
Activity
wheels-bot commentedon May 21, 2026
Wheels Bot — Triage
Classified as bug.
Suspected layer
migrator— the report identifiesvendor/wheels/Migrator.cfc:98-118as the outer-transaction wrapper, with secondary involvement from the model layer's defaulttransaction="commit"behavior.Fix sketch
The bug surfaces because
Migrator.cfcopens atransaction {}around eachup()/down()invocation, andModel.save()/.create()/.update()/.deleteAll()open a nested model-level transaction whose commit semantics conflict with the outer block on adapters whose JDBC drivers don't implement savepoint-based nesting (MSSQL confirmed; behavior on MySQL/Oracle/PostgreSQL likely differs). Likely target files:vendor/wheels/Migrator.cfc(detect or document the outer transaction context) and/orvendor/wheels/model/Transactions.cfc(skip the model's transaction wrapper when an outer transaction is already active, e.g. viacallStackGet()to detect a migrator frame, or via a request-scoped flag set byMigrator.cfcbefore invokingup()/down()). Among the reporter's suggested approaches, (1) detect-and-skip is the cleanest mechanical change; (2) warn-on-detection is a lower-risk first step; (3) is a docs-only mitigation. Picking among these is a design call propose-fix will weigh against cross-engine semantics.Confidence:
mediumReport is detailed with clear repro and root-cause analysis, but auto-downgraded because the fix touches migration files and the nested-transaction semantics differ per JDBC adapter (cross-engine concern).
<!-- wheels-bot:triage:2789 -->
<!-- wheels-bot:triage-class:bug -->
<!-- wheels-bot:triage-confidence:medium -->
wheels-bot commentedon May 21, 2026
Wheels Bot — Fix on hold for human review
The proposed fix touches a sensitive area (
vendor/wheels/Migrator.cfcouter-transaction wrapper and/orvendor/wheels/model/Transactions.cfcmodel-level transaction handling) and the bot's safety net requires a human in the loop before any code is written.Rationale:
mediumconfidence specifically because the nested-transaction semantics differ per JDBC adapter (MSSQL confirmed; MySQL / PostgreSQL / Oracle each behave differently). This is a cross-engine concern even though the engines in question are JDBC drivers rather than CFML engines.transaction="commit"default affects everymodel().save()/.create()/.update()/.deleteAll()call in the framework — not just calls from inside migrations. The blast radius warrants human design review on whether to detect-and-skip the model-level transaction, emit a warning, or pursue option 4 (per-adapter alignment of nested-transaction modes).A human reviewer should pick the approach (detect-and-skip vs warn vs adapter-level fix), decide whether the model's transaction default itself should change, and validate against the MSSQL repro before code lands.
fix(test): address Reviewer A/B consensus findings (round 2)
fix(model): bypass nested cftransaction when migrator owns the outer (#…
fix(model): SQLite recreate rollback, DEFAULT quote escaping, pg advi…