Repository navigation
fix(model): bypass nested cftransaction when migrator owns the outer (#2789) - #2810
Conversation
…2789) Model.create() / .update() / .deleteAll() inside a migration's up() or down() silently rolled back the row on MSSQL. The Migrator wraps every up() / down() invocation in its own outer cftransaction, and Wheels Model's default transaction="commit" opened a nested cftransaction whose JDBC nested-transaction semantics differ per adapter — most acutely on MSSQL where the inner commit didn't release the row and the eventual outer commit dropped it. Migrator.cfc now sets request.$wheelsTransactionWrapper = true around each up() / down() / migrateIndividual() invocation. Model::invokeWith- Transaction() reads that signal and treats the call as "alreadyopen", skipping the nested cftransaction entirely. The flag is request-scope and explicitly cleared in both the success and error paths of every migrator code path so it never leaks past the migration's outer transaction. Engine-agnostic — works the same way on MSSQL, MySQL, PostgreSQL, SQLite, Oracle, and H2. Specs: - vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc exercises invokeWithTransaction's response to the flag for create / save / deleteAll, plus a control test proving rollback still works when the flag is unset. - vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc runs an end-to-end migration whose up() calls model().create() and asserts the row persists, plus assertions that the flag is set during up() and cleared after both up() and down(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR — This PR correctly identifies and fixes a real data-loss bug: the Migrator's outer cftransaction was silently rolling back model().create() / .update() / .deleteAll() calls inside up() / down() on MSSQL because Wheels Model opened a nested cftransaction whose per-adapter commit semantics differed. The request-scope signal approach is engine-agnostic and structurally sound. However, the test spec violates the cross-engine closure-scope invariant (CLAUDE.md anti-pattern #10) in a way that can cause the spec to error or silently mis-skip the CockroachDB guard on Adobe CF, and the migration fixture stashes state in application scope when request scope is sufficient and safer. Requesting changes before merge.
Cross-engine
var _isCockroachDB captured inside it() closures — anti-pattern #10 violation
vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc, line 12:
var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB";Lines 30, 37, 42, 47 inside the it() closures:
if (_isCockroachDB) return;var in a CFML function body creates a local-scope variable. CLAUDE.md anti-pattern #10 states: "CFML closures can't access outer local vars." On Adobe CF the it() callbacks cannot see local._isCockroachDB declared in the enclosing run() method. In non-strict mode this silently evaluates to empty string (falsy) — the guard never fires and the CockroachDB-only skip is defeated. In strict mode the closures throw "variable not found" and the entire spec errors on every Adobe run.
Fix — use a struct to cross the closure boundary (the documented right form):
var ctx = {isCockroachDB: CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"};
// inside each it():
if (ctx.isCockroachDB) return;Or drop the explicit var so the assignment falls to variables scope, which closures do inherit:
_isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB";Correctness
Migration fixture stashes signal-capture state in application scope unnecessarily
vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc, lines 13–17:
application.$issue2789FlagDuringUp = (
StructKeyExists(request, "$wheelsTransactionWrapper")
&& request.$wheelsTransactionWrapper
);The PR body says "we stash on application scope (request is per-call)" — but request scope is per-HTTP-request, not per-function-call. migrator.migrateTo("001") → local.migration.cfc.up() is a chain of CFC method invocations inside the same HTTP request, so request.$issue2789FlagDuringUp is readable from the spec's it() callback after migrateTo returns. Using application scope creates global mutable state shared by all concurrently executing test workers and requires explicit StructDelete cleanup in beforeEach/afterEach to avoid cross-test pollution. request scope is naturally cleaned up at request end.
Fix:
request.$issue2789FlagDuringUp = (
StructKeyExists(request, "$wheelsTransactionWrapper")
&& request.$wheelsTransactionWrapper
);Update MigratorOuterTransactionSpec.cfc's beforeEach/afterEach cleanup lines, and the assertion:
expect(request.$issue2789FlagDuringUp).toBeTrue();Pre-existing (not introduced here, but visible in context): migrateIndividual() error path issues transaction action="commit" after transaction action="rollback"
vendor/wheels/Migrator.cfc, lines 265–271 (post-patch):
} catch (any e) {
...
transaction action="rollback";
StructDelete(request, "$wheelsTransactionWrapper"); // error path
}
StructDelete(request, "$wheelsTransactionWrapper"); // always runs
transaction action="commit"; // always runs — after rollback!Unlike the migrateTo() loops, migrateIndividual() has no break in the catch, so control always falls through to transaction action="commit" even on the error path. The new StructDelete placement is correct (flag cleared in both paths). The commit-after-rollback is pre-existing behaviour, but it's worth a follow-up issue since the fix passes through this function.
Conventions
Multi-line comment blocks in production code
vendor/wheels/Migrator.cfc — three comment blocks of 5–8 lines each (down-path lines 125–131, up-path lines 182–189, migrateIndividual lines 259–261). vendor/wheels/model/transactions.cfc lines 39–48, another 9-line block.
CLAUDE.md: "Never write multi-line comment blocks — one short line max." The WHY here is genuinely non-obvious and the issue number is the right shorthand:
// Issue #2789: skip nested cftransaction when migrator's outer one is active.
request.$wheelsTransactionWrapper = true;Tests
The test coverage is solid: the OuterTransactionSignalSpec exercises create, save, and deleteAll, includes a control test proving rollback works when the signal is absent, and the MigratorOuterTransactionSpec drives an end-to-end migration asserting row persistence. The CockroachDB guard itself is the right call since CockroachDB's auto-commit semantics make the "nested transaction bypassed → row persists even with transaction='rollback'" assertion untestable there. Fix the closure-scope bug and the tests are structurally sound.
One nit: variables.migration set in beforeAll() (line 7) is not referenced anywhere in the spec body — it appears to exist solely so helperFunctions.cfm helper calls have a migration variable available. If that's the case, a brief comment or an explicit variables.migration = ... would clarify intent over the bare unscoped assignment.
Wheels Bot — Reviewer B (round 1)A's review is substantively solid: two genuine bugs with correct diagnoses, one accurate convention call, and a correctly-labelled pre-existing observation. One minor line-number inaccuracy, no false positives, no significant missed issues. I'm aligned with the SycophancyNone detected. A supplies specific code citations and a clear rationale for each finding; the CHANGES_REQUESTED verdict is earned, not reflexive. False positivesNone detected. Verified each finding against the actual diff:
Missed issuesNone of significance. Spot-checked:
Verdict alignmentA's CHANGES_REQUESTED is consistent with the findings: two genuine bugs in test code (closure scope defeat, application scope pollution) that could cause silent mis-skips on Adobe CF and inter-test pollution. ConvergenceAligned. A's two blocking findings are correct and actionable: (1) replace |
- vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc: Replace `var _isCockroachDB` (local scope, invisible to it() closures on Adobe CF — CLAUDE.md anti-pattern #10) with `var ctx = {isCockroachDB: ...}` and update the four closure guards to `if (ctx.isCockroachDB) return;`. - vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc: Switch `application.$issue2789FlagDuringUp` to `request.$issue2789FlagDuringUp`. The migration runs in the same HTTP request as the spec's migrateTo call, so request scope is sufficient and avoids global mutable state. Spec's beforeEach/afterEach + assertion updated accordingly. - vendor/wheels/Migrator.cfc, vendor/wheels/model/transactions.cfc: Trim the three multi-line comment blocks (up-path, down-path, migrateIndividual) and the 9-line block in invokeWithTransaction to one-liners per CLAUDE.md "one short line max" convention. Issue number preserved. 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):
Skipped (intentionally, per consensus scope):
Test verification caveat: the local test runner 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
TL;DR — The round-1 fixup commit (93db8bde) correctly resolves all three findings from the prior Reviewer A review: the var _isCockroachDB closure-scope bug is fixed with the documented struct-capture pattern (var ctx = {isCockroachDB: ...}), the application.$issue2789FlagDuringUp global-state hazard is correctly moved to request scope, and multi-line comment blocks are trimmed to one-liners per CLAUDE.md. No new correctness, cross-engine, or security issues are introduced by the round-1 changes. Commenting with two minor nits; no blocking findings.
Correctness
Pre-existing migrateIndividual() commit-after-rollback (unchanged from round 1 — noted for a follow-up issue, not a blocker on this PR)
vendor/wheels/Migrator.cfc lines ~237–248 (post-patch):
} catch (any e) {
local.rv = local.rv & "Error migrating #local.migration.version#...";
transaction action="rollback";
StructDelete(request, "$wheelsTransactionWrapper"); // new, correct
}
StructDelete(request, "$wheelsTransactionWrapper"); // success path, correct
transaction action="commit"; // always runs — fires after rollback on error pathUnlike the migrateTo() loops, which break after the catch, migrateIndividual() has no enclosing loop to break out of, so transaction action="commit" always executes even after the error path issued transaction action="rollback". This is pre-existing behaviour that predates this PR — the new StructDelete placement is correct in both paths. Worth a dedicated follow-up issue rather than holding this PR.
Tests
variables.migration set in beforeAll() but never referenced by this spec's assertions
vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc, line 6:
function beforeAll() {
migration = CreateObject("component", "wheels.migrator.Migration").init();migration is correctly declared without var (so it lands in variables scope where closures can reach it), but none of this spec's describe/it blocks reference migration directly. It exists because helperFunctions.cfm uses migration.adapter.adapterName() in its engine-detection helpers (isDbCompatibleFor_SQLServer, etc.) — helpers that MigratorOuterTransactionSpec itself never calls. A one-liner would spare the next reader from wondering why it's there:
// Required by helperFunctions.cfm engine-detection helpers; migrator.paths.sql used by $cleanSqlDirectory.
migration = CreateObject("component", "wheels.migrator.Migration").init();Commits
fix(model): address Reviewer A/B consensus findings (round 1) — type fix, scope model (slightly broader than the actual touch-set, which includes migrator/ and tests, but scope is optional and unrestricted per commitlint.config.js), subject < 100 chars, not ALL-CAPS, DCO Signed-off-by trailer present. Passes commitlint.
Wheels Bot — Reviewer B (round 1)A's round-2 review is largely accurate: both observations are real and well-cited, and neither constitutes a blocker. However, A missed a class of multi-line comment violations in the test spec and migration fixture that A itself flagged in production code during round 1. The address-review bot fixed SycophancyNone detected. A uses COMMENTED (not a premature approve), cites specific file/line locations, and withholds approval while the round-1 fixes are confirmed — correct posture. False positivesNone detected. Both observations verified against the diff:
Missed issuesMulti-line comment blocks in CLAUDE.md rule: "Never write multi-line comment blocks — one short line max." A correctly flagged this in Specific instances (cumulative diff, new files only):
These were in the original submission. A flagged the production-code equivalent, B confirmed, and the address-review fixed those — but not the test-file equivalents. A reviewing the new SHA should have caught the same pattern it flagged in round 1. Verdict alignmentA's COMMENTED verdict is internally consistent with "no blocking findings, two minor observations." The verdict is not wrong, though APPROVED would also be defensible given both observations are cosmetic or pre-existing. ConvergenceNot aligned. A missed the multi-line comment violations in |
There was a problem hiding this comment.
Wheels Bot — Reviewer A response (round 1)
B's critique is correct on the missed finding and wrong on nothing. I concede the multi-line comment violations in the two test files — B caught a genuine inconsistency in my round-2 review: I flagged and got fixed the exact same pattern in Migrator.cfc and transactions.cfc during round 1, but failed to check the new test files for the same violation when reviewing the round-1 fixup commit. The core fix remains sound; the only remaining work is style-only trimming of comment blocks in the spec and migration fixture.
Conceded points
-
Multi-line comment blocks in
OuterTransactionSignalSpec.cfc— B is right. Four violations confirmed in the diff: (1) a 10-line block starting// With the outer-transaction signal set…inside the"treats invokeWithTransaction as 'alreadyopen'"it()block; (2) a 2-line block (// Establishes the baseline…) in the"control:"case; (3) a 3-line block (// Same semantics for the update path…) in thesave()case; (4) a 3-line block (// Same semantics for deleteAll…) in thedeleteAllcase. CLAUDE.md: "Never write multi-line comment blocks — one short line max." The 10-line block in particular re-describes what the surrounding code does, which CLAUDE.md explicitly prohibits. Each should compress to one line, e.g.// Issue #2789: signal set → model bypasses its own wrapper; INSERT persists despite transaction='rollback'. -
Multi-line comment block in
001_seed_tag_via_model_create.cfcdown()— The 2-line block (// removeRecord uses raw queryExecute (no nested transaction), safe for / every adapter regardless of whether the fix is in place.) is the same violation. One line:// Issue #2789: raw queryExecute avoids nested transaction; safe across all adapters. -
Missed-issue consistency — B correctly notes I should have caught these when reviewing the new SHA, having flagged the identical pattern in production code in round 1. I did not re-scan the new test files for the same class. Conceded.
Defended points
-
migrateIndividual()commit-after-rollback — B confirmed this is pre-existing and agreed it should be a follow-up issue. Position unchanged. -
variables.migrationinbeforeAll()nit — B confirmed the observation is accurate and the suggested one-liner is reasonable. Position unchanged; this was correctly labelled a nit, not a blocker.
Updated verdict
Updating to request-changes: the five multi-line comment blocks in OuterTransactionSignalSpec.cfc and 001_seed_tag_via_model_create.cfc need to be trimmed to one-liners each — identical to what the round-1 address-review applied to production code — before this merges. Once done, convergence on approve is straightforward.
Resolves CHANGELOG.md conflict with PR #2808 (upgrade check underscore-flag pre-check scans all of config/). Signed-off-by: Peter Amiri <petera@pai.com> Signed-off-by: Peter Amiri <peter@alurium.com>
Trim the multi-line comment blocks Reviewer B's round-1 review caught in the test spec and migration fixture. CLAUDE.md: 'Never write multi-line comment blocks — one short line max.' Reviewer A flagged the identical pattern in production code during round 1 and the address-review trimmed Migrator.cfc / transactions.cfc, but the same violation persisted in the new test files. Reviewer B flagged that inconsistency in round 1; this addresses it. - vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc: trimmed the 9-line block in 'treats invokeWithTransaction as alreadyopen', the 2-line block in 'control:', the 3-line block in the save() case, and the 3-line block in the deleteAll case to one-liners each, anchored on issue #2789. - vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc: trimmed the 2-line block in down() to one line. Skipped (per consensus scope): - migrateIndividual() commit-after-rollback — Reviewer A and B both labelled pre-existing; recommended a follow-up issue. - variables.migration nit in MigratorOuterTransactionSpec.cfc — both reviewers labelled this a nit, not a blocker. Local: bash tools/test-local.sh model → 843 passed; migrator → 238 passed. Pure comment-trim with zero behavior delta. Signed-off-by: Peter Amiri <peter@alurium.com>
Wheels Bot — Address Review (round 2)Applied the consensus finding Reviewer B caught in round 1 that the round-1 address-review missed:
Also merged Skipped (intentionally, per consensus scope):
Test verification:
Pure comment-trim changes with zero behavior delta. The cross-engine matrix will validate the broader fix on every supported engine × DB. The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
Reviewer A/B agreed nit from round-1 review: variables.migration was bare-assigned in beforeAll() without explanation. The variable isn't referenced by any of this spec's describe/it blocks directly; it exists because helperFunctions.cfm engine-detection helpers (isDbCompatible, isDbCompatibleFor_SQLServer, etc.) call migration.adapter.adapterName() under the hood. Spare the next reader from wondering why the assignment is there. Also tightens the adjacent migrator-init comment to one line per CLAUDE.md's 'one short line max' rule. Follow-up tracked separately for the pre-existing migrateIndividual() commit-after-rollback both reviewers flagged out-of-scope: #2811. Local: bash tools/test-local.sh migrator → 238 passed. Signed-off-by: Peter Amiri <peter@alurium.com>
|
Follow-up to the round-2 address-review: while in the spec, also picked up the two outstanding round-1 nits both reviewers had labelled non-blocking.
Cross-engine matrix validation (MSSQL repro of the original #2789 scenario) still pending on CI for the new SHA Tests: |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR — All three round-1 blocking findings and all round-2 consensus findings are cleanly resolved in the two new commits (5c6d81bd and a5c2c7f9). The core fix (Migrator sets request.$wheelsTransactionWrapper around every up()/down() call; invokeWithTransaction reads it and switches to alreadyopen mode) is engine-agnostic and structurally sound. No new correctness, cross-engine, or security issues are introduced. Approving.
What the round-2 commits fix
fix(test): address Reviewer A/B consensus findings (round 2) (5c6d81bd) trims the five multi-line comment blocks that Reviewer B caught in the new test files — identical to what the round-1 address-review applied to production code. All four comment blocks in OuterTransactionSignalSpec.cfc (the 9-line block in the alreadyopen case, the 2-line baseline block, and the two 3-line update/deleteAll blocks) and the 2-line block in 001_seed_tag_via_model_create.cfc down() are now one-liners. CLAUDE.md: «Never write multi-line comment blocks — one short line max.» ✓
docs(test): explain why variables.migration is initialized in beforeAll (a5c2c7f9) adds the explanatory comment to MigratorOuterTransactionSpec.cfc's beforeAll() that Reviewer A and B both flagged as a nit in round 2. Also notes the follow-up issue (#2811) for the pre-existing migrateIndividual() commit-after-rollback that both reviewers agreed is out of scope for this PR. DCO Signed-off-by present on both commits. ✓
Correctness
The core logic is unchanged from rounds 1 and 2; no new behaviour delta in these commits. One nit (not a blocker):
Duplicate Migration instantiation in run()
vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc, line 17:
var ctx = {isCockroachDB: CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"};variables.migration is already set in beforeAll() and accessible from run(). This could read migration.adapter.adapterName() directly instead of constructing a second instance. One extra instantiation on test setup — not a blocker, but worth a tidy-up pass if the spec is ever revised.
Commits
Both commits pass commitlint.config.js:
docs(test): explain why variables.migration is initialized in beforeAll— typedocs, scopetest, subject ≤ 100 chars, not ALL-CAPS, DCO trailer present. ✓fix(test): address Reviewer A/B consensus findings (round 2)— typefix, scopetest, subject ≤ 100 chars, not ALL-CAPS, DCO trailer present. ✓
Wheels Bot — Reviewer B (round 1)A's round-3 APPROVED review is accurate and well-supported. All blocking findings from rounds 1 and 2 are verifiably resolved, the one remaining nit is correctly labelled non-blocking, and no new issues were introduced by the two new commits. Aligned on approve. SycophancyNone detected. A cites the two specific commits, names the exact file/line for the nit, and does not approve without evidence. False positivesNone detected. Verified all claims against the diff:
Missed issuesOne very minor observation A didn't raise, but it is not blocking:
No cross-engine, security, or commit-convention issues were missed. Verdict alignmentA's APPROVED is consistent with the state of the diff: all three round-1 blockers (closure scope, application→request scope, multi-line comment blocks in test files) are resolved; the one remaining finding is a style nit A correctly did not elevate to a blocker. ConvergenceAligned on approve. The core fix is engine-agnostic, structurally sound, and correctly tested. The outstanding nit (duplicate Migration instantiation) and the cleanup-leak observation above are both below the bar for blocking a merge. Joint recommendation: approve and merge after CI (cross-engine matrix, MSSQL repro of #2789) confirms green. |
…idual() (#2811) (#2813) * fix(migrator): skip transaction commit after rollback in migrateIndividual() (#2811) `migrateIndividual()` issued `transaction action="commit"` unconditionally after its try/catch — including on the error path, where the catch block had already issued `transaction action="rollback"`. Unlike `migrateTo()`'s loops, there is no enclosing `for` to `break` out of, so control always fell through to the spurious commit. On Lucee the second action against a closed transaction is a silent no-op. On Adobe CF 2023/2025 (and potentially BoxLang) the JDBC driver may throw a "transaction not active" error that masks the real migration failure, making the underlying problem harder to diagnose. Fix: return `local.rv` from inside the catch after the rollback. Mirrors the early-exit pattern in `migrateTo()` (which uses `break` to exit its for loop and skip the commit); here we use `return` because the `transaction { }` block is not enclosed in a loop. Adds a regression spec under an isolated fixture directory (_assets/migrator-error-path/) so the synthetic always-throw migration does not pollute the shared 001/002/003 fixtures used by every other migrator spec. Diagnosed by both round-1 reviewers on #2810 and explicitly recommended as a follow-up. See issue #2811 for the full diagnosis and pattern comparison against migrateTo(). Signed-off-by: Peter Amiri <peter@alurium.com> * fix(migrator): address Reviewer A/B consensus findings (round 1) - Strip issue-number prefix from the commit-skip comment in Migrator.cfc; preserve the WHY (rollback already closed the transaction; mirrors migrateTo()'s break) per CLAUDE.md "Don't reference the current task". - Strip the trailing "Issue #2811." from the synthetic fixture's hint attribute in 004_synthetic_failing_migration.cfc. - Strip the issue prefix from the spec's intro comment and from the describe label in MigrateIndividualErrorPathSpec.cfc. - Remove the duplicate afterEach block in MigrateIndividualErrorPathSpec.cfc — beforeEach already guarantees a clean state before every it(), so the afterEach was a source of confusion rather than additional safety. The var _isCockroachDB → struct-form consistency nit (Reviewer A finding 3 / Reviewer B convergence item) was explicitly described by both reviewers as lowest-priority "can be addressed separately if desired" and is left for a follow-up to keep this commit scoped to consensus-only changes. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Summary
Fixes #2789 —
model().create()/.update()/.deleteAll()inside a migration'sup()ordown()silently rolled back the row on MSSQL.The Migrator already wraps every
up()/down()invocation in its own outercftransaction. Wheels Model's defaulttransaction="commit"opened a nestedcftransactionwhose JDBC nested-transaction semantics differ per adapter — most acutely on MSSQL, where the inner commit didn't release the row and the eventual outer commit silently dropped it. The user sawRole.create()succeed (auto-increment fired, no errors), and a follow-up rawqueryExecuteinsert of an FK row persist normally — but the role row was rolled back, leaving an orphan FK pointing at a nonexistent ID.Fix
Two-half change:
Migrator.cfcsetsrequest.$wheelsTransactionWrapper = truearound eachup()/down()/migrateIndividual()invocation, and explicitly clears it in both the success and error paths. Request-scope so it never persists past the request; never leaks across migrations because each iteration re-sets/clears.Model::invokeWithTransaction()reads the signal first. When set, the call switches to"alreadyopen"mode, skipping the nestedcftransactionentirely. The existing per-connection tracking inrequest.wheels.transactionscontinues to work as a fallback for Wheels-internal nested calls — the new check is strictly additive.Engine-agnostic: works the same way on MSSQL, MySQL, PostgreSQL, SQLite, Oracle, and H2 because the fix eliminates the nested transaction rather than working around per-engine JDBC behavior. Approach (1) from the issue body — detect outer transaction and short-circuit — picked over (2) warn-on-detection because it actually fixes the data-loss bug instead of merely surfacing it.
The signal name
$wheelsTransactionWrapperis intentionally generic — app code that wraps work in its owncftransactioncan opt in the same way:transaction { request.$wheelsTransactionWrapper = true; try { model("User").create(...); // skips its own nested transaction model("Order").create(...); } finally { StructDelete(request, "$wheelsTransactionWrapper"); } }(Docs follow-up will land in a separate PR via
bot-update-docs.yml.)Tests
vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc— four specs exercisinginvokeWithTransaction's response to the flag forcreate/save/deleteAll, plus a control test proving rollback still works when the flag is unset. The control is critical — it establishes that the rollback path actually fires in this environment, so the inverse assertion ("with the flag set, rollback is bypassed and the row persists") is meaningful.vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc— four specs running an end-to-end migration whoseup()callsmodel().create(), asserting the row persists; that the flag is set duringup(); and that it's cleared after bothup()anddown()return. Uses a separate test-migrations directory (_assets/migrator/migrations_2789/) so the synthetic001_seed_tag_via_model_create.cfcdoesn't collide with the reconciliation spec's fixtures.Local:
bash tools/test-local.sh— 3780 passed (Lucee 7 + SQLite). Cross-engine matrix runs in CI.Test plan
bash tools/test-local.sh model— greenbash tools/test-local.sh migrator— greenbash tools/test-local.sh(full) — greenPull request checklist
vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfcandvendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfcbot-update-docs.ymlbot-update-docs.ymlbot-update-docs.ymlFixes #2789
🤖 Generated with Claude Code