Skip to content

feat(article): drop the markdown body from list responses - #213

Merged
aquie00t merged 1 commit into
mainfrom
feat/article-list-summaries
Aug 25, 2026
Merged

feat(article): drop the markdown body from list responses#213
aquie00t merged 1 commit into
mainfrom
feat/article-list-summaries

Conversation

@aquie00t

Copy link
Copy Markdown
Collaborator

What does this PR do?

ArticleItemSchema served both the detail endpoint and every list endpoint, so GET /articles and GET /articles/me returned the full markdown of every item. The schema caps a body at 100 000 characters, which makes a page of fifty articles megabytes of text no list view renders — and that was also what Redis cached for 60 seconds, per viewer.

Measured

Booted the app against the dev database with one 28 KB article:

response before after
GET /articles?limit=10 28 930 bytes 924 bytes
GET /articles/:slug 28 930 bytes 28 930 bytes (unchanged)

At limit=50 that is roughly 1.4 MB versus 46 KB per page.

How

  • ArticleSummarySchema = FBType.Omit(ArticleItemSchema, ["body"]) — derived, not a hand-written twin, so a field added to the item schema appears here automatically.
  • ArticleSummaryResponse = Omit<ArticleResponse, "body"> in the mapper, with toSummaryResponse holding every shared field and toResponse spreading body onto it. One place to change, so the two shapes cannot drift.
  • Narrowing toListResponse's return type propagates through ArticleController.list / mine into the route Reply generics — so the type system enforces this, rather than fast-json-stringify quietly dropping a field it does not recognise.

The detail, create, update, publish and archive responses are untouched and still carry the body.

What is deliberately not touched

The repository selects. The repo uses include, not select, so body is still fetched from Postgres. Removing it there means switching to select, which breaks the ArticleWithRelations cast and Article.with(), which requires body: string. That cascades into either an optional body on the domain entity or a second read model — real work, unrelated to this.

The Redis cache DTO. Stripping body from CachedArticle would feed an empty string into Article.with(). If a body ever returns to a list response, cached pages would serve "" while uncached pages served real markdown — a bug that appears and disappears on a 60-second cycle. Both belong in one later change with an explicit summary read model.

Timing

Doing this now costs nothing: the article migrations have not been applied to production, so no client is reading these responses yet. Later, it would be a breaking change.

Verification

lint, format:check, build clean; 755 unit tests pass.

Three new e2e assertions in tests/e2e/article/read.test.ts: list items carry excerpt, coverImageUrl and readingTimeMinutes but not body; /articles/me likewise; and the detail endpoint still returns a non-empty body.

Verified end to end by booting the real app: create → publish → list → detail → the numbers in the table above.


Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Chore

Checklist

  • My branch follows the naming convention (feature/, fix/, chore/, docs/)
  • My commits follow Conventional Commits
  • I have tested my changes locally
  • I have not introduced any breaking changes
  • I have updated relevant documentation if needed

First of three: this one lands before the profile articleCount so the profile Articles tab is never shipped against megabyte pages.

🤖 Generated with Claude Code

ArticleItemSchema served both the detail endpoint and every list endpoint, so
GET /articles and GET /articles/me returned the full markdown of every item.
The schema caps a body at 100 000 characters, which makes a page of fifty
articles megabytes of text no list view renders.

Measured against a single 28 KB article: the list page went from 28 930 to
924 bytes. At limit=50 that is roughly 1.4 MB versus 46 KB, and the larger
payload was also what Redis cached for 60 seconds per viewer.

ArticleSummarySchema is an Omit of the item schema rather than a hand-written
twin, and toSummaryResponse now holds every shared field with toResponse
spreading the body onto it, so the two shapes cannot drift apart. Narrowing
toListResponse propagates through the controller into the route generics, so
the type system enforces this rather than fast-json-stringify quietly
dropping a field.

The repository selects and the Redis cache DTO are deliberately untouched.
The repo uses include rather than select, so removing body there would break
the ArticleWithRelations cast and Article.with, which requires a body. And
stripping body from CachedArticle would feed an empty string into the entity:
if a body ever returns to a list response, cached pages would serve "" while
uncached pages served real markdown - a bug that appears and disappears on a
60 second cycle. Both belong in a separate change with an explicit summary
read model.

Doing this now costs nothing: the article migrations are not yet applied to
production, so no client is reading these responses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aquie00t
aquie00t merged commit 628d897 into main Aug 25, 2026
10 checks passed
@aquie00t
aquie00t deleted the feat/article-list-summaries branch August 25, 2026 05:13
github-actions Bot pushed a commit that referenced this pull request Aug 25, 2026
# [1.7.0](v1.6.0...v1.7.0) (2026-08-25)

### Features

* **article:** drop the markdown body from list responses ([#213](#213)) ([628d897](628d897))
* **profile:** expose published articleCount on the profile response ([#214](#214)) ([1b030bf](1b030bf))

### Performance Improvements

* **profile:** resolve follower list targets without loading the full profile ([#215](#215)) ([b855269](b855269))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.7.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant