Skip to content

fix(cli): scaffolded controllers 404 on a missing or soft-deleted record - #3607

Merged
bpamiri merged 1 commit into
developfrom
fix/scaffold-404-guard
Sep 13, 2026
Merged

bpamiri merged 1 commit into
developfrom
fix/scaffold-404-guard

Conversation

@bpamiri

@bpamiri bpamiri commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

show, edit, update and delete called findByKey bare. It returns a non-object for both a missing key and a soft-deleted row (deletedAt set), so the action operated on an empty value and the view threw — a 500 where a 404 belongs. Reproduced on a soft-deleted post during the demo rehearsal. The crud and api-resource generators already guard this; the scaffold was the outlier.

The fix

A requireRecord before filter throws Wheels.RecordNotFound, which the framework maps to 404:

filters(through="requireRecord", only="show,edit,update,delete");

private function requireRecord() {
    if (!IsObject(model("Post").findByKey(key=params.key))) {
        Throw(type = "Wheels.RecordNotFound", message = "Post not found for the requested key.");
    }
}

It deliberately does not assign the record. The actions keep their conventional single-finder body, which the --belongsTo parent wiring depends on — ScaffoldSource.showInclude() only rewrites show() when the finder is its entire body, so an inline guard there silently disables include= wiring. Loading twice is cheap and preserves that contract.

Why the first three attempts failed

Worth recording so nobody repeats them.

ScaffoldSource.scan() fails closed on any interpolated string. A single #params.key# anywhere in the file returns valid=false; method() then can't find show(), and the parent wiring reports "skipped". My guard's message was interpolating the key. It's now a plain string — and the new spec asserts the generated file scans, so this can't regress quietly.

A spec forbids the bare positional findByKey(params.key) anywhere in the controller (the mixed positional+named form is rejected at runtime). The filter uses the all-named form.

Verified on a generated app

request before after
/posts/12 (soft-deleted) 500 404
/posts/99999 (never existed) 500 404
/posts/12/edit 500 404
/posts/1 (real) 200 200, eager-loaded comments still listed

That last row is the one that proves the --belongsTo wiring survives.

CLI suite: 1356 pass, the 4 pre-existing DbCommandSpec failures, 0 errors. Complexity gate: PASS.

show(), edit(), update() and delete() called findByKey bare. It returns a
non-object for BOTH a missing key and a soft-deleted row (`deletedAt` set), so
the action operated on an empty value and the view threw — a 500 where a 404
belongs. Reproduced on a soft-deleted post. The `crud` and `api-resource`
generators already guard this; the scaffold was the outlier.

A `requireRecord` BEFORE filter now throws Wheels.RecordNotFound, which the
framework maps to 404. It deliberately does not assign the record: the actions
keep their conventional single-finder body, which the `--belongsTo` parent
wiring depends on. ScaffoldSource.showInclude() only rewrites show() when the
finder is its entire body, so an inline guard there silently disables the
include= wiring. Loading twice is cheap and preserves that contract.

Why three earlier attempts failed, so nobody repeats them:

  ScaffoldSource.scan() fails CLOSED on any interpolated string — a single
  `#params.key#` anywhere in the file returns valid=false, method() then
  cannot find show(), and the parent wiring reports "skipped". The guard's
  message was interpolating the key. It is now a plain string, and the
  generated file must scan; a new assertion pins that at the source so this
  cannot regress quietly.

  The filter's finder also uses the all-named `findByKey(key=params.key)`:
  a spec forbids the bare positional form anywhere in the controller, since
  the mixed positional+named shape is rejected at runtime.

Verified on a generated app: soft-deleted /posts/12 and nonexistent
/posts/99999 -> 404 (both were 500); /posts/12/edit -> 404; real /posts/1 ->
200 with its eager-loaded comments still listed, proving the wiring survives.

CLI suite: 1356 pass, the 4 pre-existing DbCommandSpec failures, 0 errors.
Complexity gate: PASS.

Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri
bpamiri merged commit eb4e305 into develop Sep 13, 2026
12 checks passed
@bpamiri
bpamiri deleted the fix/scaffold-404-guard branch September 13, 2026 03:18
bpamiri added a commit that referenced this pull request Sep 13, 2026
…3608)

#3607 added the requireRecord filter to templates/codegen/CRUDContent.txt and
was verified on a generated app — but a STOCK `wheels new` app never got it.

`wheels new` copies every codegen template into the app's app/snippets/, and
Templates.cfc resolves those FIRST: they shadow the bundled copy. So the fix
landed in the bundled template while the app-shipped twin silently overrode it
with the old, guard-less body. Release 2487 carried the fix; a fresh app
generated from 2487 did not. My verification had hand-written the rendered
template into the controller, which is why it looked fixed.

The snippet copy is now byte-identical to the bundled one. Confirmed on a
genuinely stock app (fresh `wheels new`, real scaffold, real delete action, no
edits): soft-deleted /posts/10 -> 404, /posts/99999 -> 404, /posts/10/edit ->
404, /posts/1 -> 200.

A new spec pins the pair. Only this pair: two other snippet twins differ on
purpose (the app copies read the reload password from .env), so a blanket
"all snippets match codegen" rule would be wrong. 11 of 13 twins were already
identical; this was the one that drifted.

CLI suite: 1357 pass, the 4 pre-existing DbCommandSpec failures, 0 errors.

Signed-off-by: Peter Amiri <peter@alurium.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant