Skip to content

feat(core): add getBylines() for listing a site's bylines - #2352

Open
MA2153 wants to merge 9 commits into
emdash-cms:mainfrom
MA2153:feat/get-bylines
Open

MA2153 wants to merge 9 commits into
emdash-cms:mainfrom
MA2153:feat/get-bylines

Conversation

@MA2153

@MA2153 MA2153 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

There is no way for site code to enumerate bylines. The public API offers only identifier-keyed lookups — getByline(id), getBylineBySlug(slug) — plus getEntriesByByline(), all of which need a byline you already have. The only site-wide enumeration, BylineRepository.findMany(), is reachable solely through the admin-gated GET /_emdash/api/admin/bylines.

So the only thing a frontend can do today is query a content collection and dedupe entry.data.bylines. That reaches _emdash_bylines through a join on _emdash_content_bylines (itself reached through a content query) to read columns sitting on the byline row, and the result is a function of which entries you fetched: bounded by the content query's limit/cursor, and silently missing anyone with no resolvable credits — a guest author added ahead of publication, an author whose posts are all draft or scheduled, an author whose entries exist only in another locale.

This adds one export:

const { items, nextCursor } = await getBylines({ locale, limit, cursor });

backed by BylineRepository.findManyAlphabetical(), a byline-table-driven read:

  • Media join included. LEFT JOIN media on avatar_media_id, the same join getContentBylinesMany already performs. Without it every caller rendering avatars does a MediaRepository.findById per byline — an N+1 across exactly the page being built.
  • Strict locale, resolved like getTaxonomyTerms. Byline rows are per-locale, so filtering on the resolved locale yields one row per person and needs no translation-group dedupe.
  • No custom-field hydration. findMany calls withCustomFields unconditionally, costing extra queries against the EAV tables. A list of names and avatars doesn't need them; customFields comes back {}, matching the existing skipHydration behaviour. An includeCustomFields: true flag would be additive later.
  • Alphabetical, cursor-paginated. findMany orders by created_at DESC, which is wrong for an author index. Cursor keyed on (display_name, id) via the existing encodeCursor/decodeCursor; returns { items, nextCursor? } per the pagination convention.
  • Wrapped in requestCached, keyed on every argument.

Cost: one query. Opt-in, so no existing route's query count moves and the snapshots are unaffected. One additive migration (068, a column on _emdash_bylines — see the review follow-up below), no change to existing behaviour or exports — not a breaking change.

Deliberately left out, each additive later: search/isGuest filters, an includeCustomFields flag, and adding the media join to findMany so the admin list stops N+1-ing avatars (a separate change to an admin path that shouldn't ride along).

Index behaviour

_emdash_bylines has single-column indexes on display_name and locale (migration 040), but no composite. The locale filter is indexed; the ordering is not, so each page sorts the locale partition before LIMIT applies and the cursor is not a true seek. This is index-availability only — it does not depend on sqlite_stat1. At the byline counts real sites carry, sorting the partition is negligible, and a (locale, display_name_sort) composite is a forward-only migration whenever that stops being true. That belongs with #1532, which covers the same concern across taxonomy terms, bylines, users, and media.

Ordering moved to display_name_sort (below), which carries no index at all, so the plan is unchanged: the planner never used idx_bylines_display_name for this query either — the locale filter won, and the sort was already a temp B-tree. 068 deliberately adds no index, to keep the composite question in one place.

Review follow-up

Pre-migration databases (#2352 (comment)). Fixed in 807c633getBylines() was the odd one out: getTaxonomyTerms returns [] and getBylinesForEntries further down the same file already catches this, while getBylines() propagated the no such table error. It now catches isMissingTableError and returns { items: [] }. Covered by a new case in tests/unit/bylines/bylines-query.test.ts that drops _emdash_bylines and asserts the empty result; it fails with the SqliteError before the fix.

Collation (#2352 (comment)). Fixed in 1dbe279. Confirmed: ordering on display_name used the database's own collation, BINARY on SQLite, so the list read Adam, Zoe, alice, Álvaro — and Øyvind landed after Zoe too, since ø doesn't decompose.

Migration 068 adds _emdash_bylines.display_name_sort: the display name casefolded, NFD-decomposed with combining marks dropped, and with the Latin letters that carry no mark to drop (æ œ ø ð þ ß đ ł ħ ı ŋ ŧ ĸ) expanded (utils/sort-key.ts). create and update write it, and the migration backfills existing rows in batches sized for D1's parameter ceiling, following 056's pattern. findManyAlphabetical orders on that column and seeks its cursor on it, so the two compare the same value by construction — which is the half that actually breaks pagination if they diverge.

It was numbered 059 when those threads were written; main has since claimed 059067, so successive merges have renumbered it to 068. Nothing about it changed but the number.

Stored rather than computed in the query, for two reasons: neither dialect can produce this key in SQL (SQLite's lower() is ASCII-only, NOCASE folds ASCII case and nothing else, and no dialect has unaccent available on D1), and a cursor value computed in JS has to compare equal to what SQL sorted. A side effect is that the order no longer depends on the dialect's collation: a Latin-script key is plain ASCII, so SQLite and Postgres agree. Scripts outside Latin keep their letters and still sort by code point — that needs ICU, which D1 doesn't carry, and it's no worse than today.

Covered by tests/unit/database/migrations/068_byline_display_name_sort.test.ts (backfill, including a row past the first batch; a re-run against a database that already has the column; down()), plus three cases in the repository test: the mixed-case/accent ordering, the same list walked page by page (this is the one that fails if ORDER BY and the cursor disagree), and a rename re-sorting the row. The first two fail on main's ordering; the rename case fails if update stops writing the key — I checked each by reverting the relevant line.

Stale request cache (#2352 (comment)). Fixed in 1dbe279. Confirmed, and the keys carry locale, limit, and cursor, so a write path can't name the entries it invalidates — hence clearRequestCachePrefix, alongside the existing clearRequestCacheEntry. create, update, and delete drop the whole bylines: namespace, next to the invalidateBylineObjectCache() call each already makes. The key is built in one place (bylines/cache-keys.ts) so the reader and the write paths can't drift. Covered by a case that reads, creates, renames, and deletes inside one runWithContext, asserting the list after each; it returns the stale first page before the fix.

Scoped to the key this PR introduces. byline-by-slug:, taxonomy-terms:, menu:, and siteSetting: have the same gap today and none are cleared on write — worth a sweep, but not from this PR.

Object-cache layer deferred to a follow-up, as agreed. getBylines() stays on requestCached only for now.

Implements #2333. No issue to close.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — full packages/core suite: 5668 passed, 9 skipped. packages/registry-verification fails on my machine for an unrelated environment reason (node choking on the pnpm binary); it fails identically on a clean checkout of main.
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, no admin UI in this PR
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Add a public `getBylines()` so site code can list bylines without going through content #2333

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

Tests, all new:

  • tests/unit/database/repositories/byline.test.ts — alphabetical order with pagination covering every row exactly once; mixed-case and accented names alphabetized, and that same list walked page by page; a rename re-sorting the row; a display-name tie split across a page boundary (the id half of the cursor); the media join populating avatarStorageKey/alt/blurhash/dominant colour; locale returning one row per person; customFields staying {} while findById still reads the seeded value.
  • tests/unit/bylines/bylines-query.test.ts — a guest author and a draft-only author both appearing in the list (the gap this closes); implicit locale resolving to the configured default rather than returning every locale variant; no fallback when the requested locale has no row; cursor pagination; an empty result on a database missing _emdash_bylines; writes during a request invalidating the list read earlier in it.
  • tests/unit/database/migrations/068_byline_display_name_sort.test.ts — the backfill, a row past the first batch, a re-run against a database that already has the column, and down().
  • tests/integration/byline-list-plan.test.ts — a query-plan guard following the existing *-plan.test.ts pattern (no ANALYZE, since D1 never maintains sqlite_stat1): locale-index seek with and without a cursor, media reached by primary key, no SCAN on either table. Index names are matched by prefix regex so a later locale-leading composite doesn't fail the test on an improvement.
|--SEARCH b USING INDEX idx__emdash_bylines_locale (locale=?)
|--SEARCH m USING INDEX sqlite_autoindex_media_1 (id=?) LEFT-JOIN
`--USE TEMP B-TREE FOR ORDER BY

I checked the plan test fails on a real regression rather than just passing: dropping idx__emdash_bylines_locale fails the two seek assertions, and removing the media join fails the primary-key one.

🤖 Generated with Claude Code

Site code had no way to enumerate bylines. The public API offered only
identifier-keyed lookups, so the only option was querying a content
collection and deduping entry.data.bylines -- a list bounded by whichever
entries were fetched, and one that drops anyone with no resolvable credits
(a guest author added ahead of publication, an author whose entries are all
drafts, an author whose entries exist only in another locale).

getBylines() reads _emdash_bylines directly: one query, alphabetical by
display name, cursor-paginated on (display_name, id), with the avatar's
media columns joined in so rendering avatars costs no extra query per
byline. Locale is strict, resolved like getTaxonomyTerms. No custom-field
hydration -- customFields comes back {}, as skipHydration does elsewhere.

Opt-in, so no route's query count moves. No migration, no schema change.

Implements emdash-cms#2333

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5142e2f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR proposes a new public getBylines() runtime API and the underlying BylineRepository.findManyAlphabetical(). The implementation matches EmDash's existing patterns: cursor pagination, strict per-locale filtering via resolveLocale, media joined in a single query, and customFields deliberately omitted. The tests cover ordering, pagination, cursor tie-breaking, locale filtering, the media join, and a SQLite query-plan regression guard. The changeset and docs updates look correct.

However, the PR should not merge as-is for process reasons. AGENTS.md requires a prior maintainer-approved Discussion for any feature, and the PR description explicitly notes that Discussion #2333 has no maintainer response yet. Treating this as a proposal is the right posture, but that means the appropriate next step is maintainer approval of the Discussion before the feature lands — not code review as a merge candidate. Code quality aside, merging without that approval would violate the project's contribution policy.

On the code: I found one real robustness gap. The new public function does not degrade gracefully when the bylines table is missing, while the analogous getTaxonomyTerms() returns [] on isMissingTableError. Because bylines were added in migration 031 and the table may be absent in pre-migration or isolated database contexts, the new API should follow the same pattern. I would also suggest evaluating whether this list should use the object cache (cachedQuery under CacheNamespace.BYLINES) like getTaxonomyTerms(), since a public author index is exactly the kind of rarely-changing, locale-keyed list that benefits from cross-request caching.

Overall: technically sound proposal, but it needs the approved Discussion first and the missing-table guard before it could safely ship.

Comment thread packages/core/src/bylines/index.ts
Comment thread packages/core/src/bylines/index.ts
@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label Aug 7, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good. One change before merge. Deferring the object-cache layer to a follow-up is fine.

Comment thread packages/core/src/bylines/index.ts
MA2153 and others added 2 commits August 9, 2026 11:58
getBylines() threw where its sibling enumeration helpers return empty:
getTaxonomyTerms returns [], and getBylinesForEntries in the same file
already catches the missing-table case. Catch isMissingTableError and
return { items: [] } so a public helper behaves like the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added area/core area/docs size/XL review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 9, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2352

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2352

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2352

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2352

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2352

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2352

emdash

npm i https://pkg.pr.new/emdash@2352

create-emdash

npm i https://pkg.pr.new/create-emdash@2352

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2352

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2352

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2352

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2352

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2352

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2352

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2352

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2352

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2352

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2352

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2352

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2352

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2352

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2352

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2352

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2352

commit: 5142e2f

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness issues block the advertised alphabetical list and fresh same-request reads.

Comment thread packages/core/src/database/repositories/byline.ts Outdated
Comment thread packages/core/src/bylines/index.ts Outdated
…ites

Ordering `findManyAlphabetical` on `display_name` used the database's own
collation — BINARY on SQLite, which files `Adam, Zoe, alice, Álvaro`. Migration
059 adds `_emdash_bylines.display_name_sort`, a casefolded, accent-stripped
copy of the display name written on create and update and backfilled for
existing rows. The list orders and seeks its cursor on that column, so the
`ORDER BY` and the cursor predicate compare the same thing, and a Latin-script
key is plain ASCII, so SQLite and Postgres agree on the order.

`getBylines()` is request-cached under keys carrying locale, limit, and cursor,
which a write path can't reconstruct, so a read → write → read sequence within
one request served the stale first result. Byline create, update, and delete
now clear the whole `bylines:` namespace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

main claimed 059-064 while this branch was open, so the byline sort-key
migration is renumbered 059 -> 065. Nothing about it changes but the number;
none of main's new migrations touch _emdash_bylines, so running last is safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MA2153 MA2153 added needs-rebase bot:review Trigger an emdashbot code review on this PR and removed needs-rebase labels Aug 12, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of #2352. The code-level issues from the previous review have been addressed: getBylines() now catches isMissingTableError and returns { items: [] }, matching the graceful degradation of getTaxonomyTerms(). The author explicitly links the PR to Discussion #2333 and ticks the "approved Discussion" checklist item; I cannot verify the current approval status from the repository alone, so I am treating it as claimed-resolved rather than confirmed.

What I checked:

  • The new public getBylines() runtime API, its request-cache keying, and its strict per-locale resolution via resolveLocale().
  • The new BylineRepository.findManyAlphabetical() implementation: cursor pagination on (display_name_sort, id), media join for avatars, customFields = {}, and limit clamping.
  • Migration 065_byline_display_name_sort: safe parameterized backfill, idempotent up(), guarded down(), and batching under D1's bind-parameter ceiling.
  • Write-path cache invalidation (clearRequestCachePrefix) applied in create, update, and delete.
  • Type changes to BylineTable / BylineRowWithAvatar and export wiring.
  • Tests covering missing-table fallback, locale filtering, cursor pagination, ordering, sort-key backfill, query plans, and write-time request-cache invalidation.

I found no logic bugs, SQL-safety issues, regressions, or AGENTS.md convention violations in the diff. The implementation follows EmDash's established patterns for cursor pagination, locale filtering, media joins, request caching, and migration discipline. If the maintainer confirms Discussion #2333 is approved, the code is ready to merge.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 12, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond needs-rebase and removed review/needs-rereview Author pushed changes since the last review labels Aug 12, 2026
main claimed 065 for the media-usage collection-deletion migration, so
the byline display_name_sort migration moves to 066.
@MA2153
MA2153 requested a review from ascorbic August 13, 2026 08:27
@ascorbic

Copy link
Copy Markdown
Collaborator

I'm afraid It needs a migration renumbering.

MA2153 and others added 2 commits August 13, 2026 16:37
main claimed 066 for 066_media_usage_reconciliation, so the byline sort-key
migration is renumbered to 067.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main claimed 067 for 067_indexed_content_fields, so the byline sort-key
migration is renumbered to 068.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MA2153

MA2153 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Renumbered

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

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.

2 participants