Skip to content

Derive excerpts on every save, and stop 404ing unchanged saves - #214

Merged
ssavutu merged 2 commits into
mainfrom
fix/blank-excerpts-and-no-op-saves
Aug 9, 2026
Merged

Derive excerpts on every save, and stop 404ing unchanged saves#214
ssavutu merged 2 commits into
mainfrom
fix/blank-excerpts-and-no-op-saves

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 9, 2026

Copy link
Copy Markdown
Member

Two independent write-path bugs, both found chasing the empty excerpts on the Sports section page.

Blank excerpts

The excerpt box is optional and the editor sends it on every save whether or not the author filled it in. POST treated a blank one as "derive from the body"; PUT and PATCH stored the blank verbatim. An article got an excerpt on create and lost it on the first edit afterward.

This became visible when listings stopped selecting the article body: they render excerpt and nothing else, so a blank column is a story that appears on every section page and the homepage with no summary. 390 of 10,049 articles were in that state.

ExcerptOrDerived is now the single path for every write that sets the column. PATCH gets a real case in the field switch rather than falling through to the write-it-verbatim default, deriving from the patch's own body when it carries one and from the stored body when it doesn't.

cmd/backfill-excerpts repairs the already-blanked rows through the same function. It skips articles with nothing to derive from — image-only posts, political cartoons, crossword embeds — because an excerpt made of markup is worse than none.

Already run against production: 42 repaired, 358 correctly left alone, second run is a no-op. It deliberately does not touch mod_date: this is a repair of a value the CMS should have written, not an edit, and bumping it would move 42 articles' sitemap lastmod and re-queue them all for embedding.

Unchanged saves answered as 404

Six handlers read RowsAffected and treat zero as "no such row". MySQL reports changed rows, not matched rows, so an UPDATE that matched its row and changed nothing reported zero — and the author was told the article they have open does not exist. Two saves inside the same second are enough, since mod_date is stamped to the second. Archive and restore have no timing element at all: archiving an already-archived author 404s.

clientFoundRows=true asks for matched rows, which is what all six call sites already mean. No call site uses RowsAffected for change detection.

The integration harness now adds the flag when CMS_TEST_DSN lacks it — without that, the tests exercise semantics production never runs with, which is how this survived a suite that covers these handlers closely.

Testing

Full suite passes against a real MariaDB. The no-op test was verified to reproduce the bug: forced to clientFoundRows=false it fails with exactly the 404 article not found an editor would see.

Note

The second fix is not what is behind the ~2% resting error rate on the public API. That is inbound WordPress-era URLs — stale tag archives and legacy author logins — and every failing request there is a GET. The fix for that is Scalene's fix/author-slug-resolution branch.

🤖 Generated with Claude Code

ssavutu and others added 2 commits August 8, 2026 23:49
The excerpt box is optional and the editor sends it on every save, filled in
or not. POST treated a blank one as "derive it from the body"; PUT and PATCH
stored the blank verbatim. So an article got an excerpt when it was created
and lost it on the first edit afterward, and nothing ever put one back.

That was survivable while listings selected the body: they didn't read it,
but it was there. It isn't any more -- listings select `excerpt` and no
longer fetch `text` at all, so a blank column is a story that renders on the
section pages and the homepage with no summary under it. 390 of 10,049
articles were in that state, the whole CMS-era corpus; the Sports page was
three stories with nothing beneath the headlines.

ExcerptOrDerived is now what every write that sets the column goes through,
so the three paths cannot disagree again. PATCH gets a real case in the
field switch instead of falling through to the write-it-verbatim default: it
derives from the patch's own body when the save carries one and from the
stored body when it doesn't, so an excerpt-only autosave still gets a
summary. A non-string is a 400, matching the other typed fields.

cmd/backfill-excerpts repairs the rows already blanked, through the same
ExcerptOrDerived, so a repaired row is byte-identical to what a save writes
today. It leaves an article alone when there is nothing to derive from --
image-only posts, political cartoons, the crossword embeds -- because an
excerpt made of markup is worse than none. Run against production: 42
repaired, 358 left alone, and a second run finds nothing to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six handlers read RowsAffected and treat zero as "no such row": author
update, patch, archive and restore, and both article write paths. MySQL
reports rows whose values it changed, not rows it matched, so an UPDATE that
matched its row and changed nothing reports zero -- and a save that happened
to change nothing was answered 404, telling an author the article they have
open no longer exists.

The editor sends the whole form on every save and autosaves on a timer, and
mod_date is stamped to the second, so two saves inside the same second are
enough: the second rewrites every column to what it already holds. Archive
and restore are worse, because there is no timing element -- archiving an
already-archived author reports that the author is not there.

clientFoundRows asks the server for matched rows instead, which is what all
six call sites already mean. No call site uses RowsAffected to ask whether
anything changed, so nothing else moves.

The integration harness adds the flag to CMS_TEST_DSN when it is missing.
Without that the tests exercise semantics production never runs with, which
is how this survived a suite that covers these handlers closely: forced back
to clientFoundRows=false, the new test reproduces the 404 exactly.

This is not what was behind the resting error rate on the public API -- that
is inbound WordPress-era URLs, and every failing request there is a GET.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit d8d6469 into main Aug 9, 2026
6 checks passed
@ssavutu
ssavutu deleted the fix/blank-excerpts-and-no-op-saves branch September 1, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant