feat: extensible bylines#1258
Conversation
…ation for admin API
🦋 Changeset detectedLatest commit: 8053d0b The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
There was a problem hiding this comment.
This is a large, well-architected feature PR that adds extensible byline custom fields via a new registry table, per-locale vs group-shared storage split, a parity-aware version cache, D1 idempotent-recovery on create, and a full admin UI. It follows the approved Discussion #1174 and builds cleanly on the i18n byline foundation.
Approach: The schema-in-database approach with _emdash_byline_fields + options.byline_fields_version is idiomatic for EmDash and mirrors the settings cache pattern. The translatable/group-shared split is the right modeling for the i18n architecture. The D1 crash-recovery bookend (dirty/clean parity bit) is carefully done and documented.
What I checked:
- SQL safety: no raw interpolation, Kysely throughout, identifiers validated where needed.
- Authorization: GET endpoints use
schema:read(Editor+), mutations useschema:manage(Admin). Bylines routes keep existingbylines:read/bylines:managegates. - Locale filtering: byline lookups and credit hydration are locale-strict as required.
- Admin UI: all strings go through Lingui (
t/msg/plural), Tailwind uses logical classes (text-start,me-*,end-*, etc.). - Caching: per-isolate + per-request two-tier cache for field defs, with isolated-DB bypass. Group-shared values use
requestCachedwith explicit invalidation on write. - Schema/migration: migration 041 is additive, registered in runner, uses
ifNotExistsand proper index discipline. - Tests: extensive coverage across unit, integration, and e2e layers including concurrency races, auth gates, route registration, D1 recovery, and admin form behavior.
Headline conclusion: The implementation is solid and the test surface is impressive. I found no logic bugs, security issues, or convention violations. Two very minor suggestions below.
| * | ||
| * Iterates `summaries` (not a `summaryById` map) so duplicate | ||
| * `BylineSummary` objects sharing the same `id` — e.g. the same | ||
| * author credited to multiple entries — each get their own merged |
There was a problem hiding this comment.
[suggestion] applyCustomFieldsTo redundantly initializes summary.customFields = {} for every summary, but both of its callers (withCustomFields and hydrateBylineCustomFields) already do the same initialization right before invoking it. Since applyCustomFieldsTo is private, the inner loop is unnecessary.
| * author credited to multiple entries — each get their own merged | |
| private async applyCustomFieldsTo(summaries: BylineSummary[]): Promise<void> { | |
| if (summaries.length === 0) return; | |
| const defs = await getBylineFieldDefs(this.db); |
|
/review |
There was a problem hiding this comment.
Reviewed the registry, parity-bit cache, repo D1-recovery logic, route auth gates, and admin UI. Approach is solid: schema-in-database is consistent with the rest of EmDash, the parity-bit bookend is carefully documented, the per-isolate + per-request two-tier cache correctly bypasses the global holder on isolated DBs and dirty versions, and the row-per-locale vs group-shared split is enforced both at storage and at hydration time (the field.translatable gate at byline.ts:391/401 stops orphaned cross-table rows from leaking through). Dialect-parity test coverage for the concurrency races and the D1 partial-write recovery branches is impressive.
Investigated and considered OK:
bylineFixedFieldsMatchcorrectly normalizesuserId/bio/avatarMediaId/websiteUrlundefined→null on input to match the repo's?? nullstorage, and correctly mapsisGuest ?? falseagainst the stored0/1.existingCustomFieldsAreSubsetOfuses!==which is safe forCustomFieldValue = string | boolean | null.- The
effectiveLocale === existing.localecheck correctly rejects recovery if the configureddefaultLocalechanged between original POST and retry. - Route registration order in
astro/integration/routes.tsputs the staticreorderbefore the dynamic[slug];reorderis also inRESERVED_BYLINE_FIELD_SLUGS(defense in depth). markVersionDirty/markVersionCleanSQL is dialect-compatible (CAST AS INTEGER,%,ON CONFLICT DO UPDATEall work on SQLite + PG).- The previous reviewer's note about
applyCustomFieldsTore-initializingcustomFieldsis actually incorrect — the inner method does NOT re-init; only the public callers do.
Findings below are nitpicks. One more I couldn't anchor: getBylineBySlug in packages/core/src/bylines/index.ts populates a byline-by-slug:${slug}:${chain} request-cache key (line 91) but BylineRepository.update doesn't invalidate it after writes — it only clears byline-field-group-values:${group}. Within a single request, a getBylineBySlug → updateByline → getBylineBySlug sequence returns stale data on the second call. Unreachable from the admin SPA (which works in byline IDs end-to-end) so this is only a footgun for plugin/runtime code that mixes both. Worth either clearing on write or commenting the read-after-write inconsistency at the helper.
Apologies for the four test-review comments above — fork-mode review tooling doesn't let me delete already-submitted reviews. The inline comments below are the actual review.
| * when the row is missing (matches `BylineSchemaRegistry.getVersion`). | ||
| */ | ||
| async function getBylineFieldsVersion(db: Kysely<Database>): Promise<number> { | ||
| return requestCached(REQUEST_CACHE_KEY_VERSION, () => new BylineSchemaRegistry(db).getVersion()); |
There was a problem hiding this comment.
[doc nit] The JSDoc on BylineSchemaRegistry.getVersion claims returning 0 for a missing options row "covers the tests that didn't run migration 041 case without throwing". That's only half true at this call site — if migration 041 didn't run, _emdash_byline_fields doesn't exist either, so the subsequent listFields() call in getBylineFieldDefs will throw on the missing table anyway. The graceful-zero only helps if the options row was manually deleted while the field tables stayed put.
Not blocking — but consider either tightening the JSDoc on getVersion to reflect that, or guarding this call site with isMissingTableError for the no-migration case (matches the pattern in bylines/index.ts:getBylinesForEntries).
| const { data: customFieldsList, error: customFieldsError } = useQuery({ | ||
| queryKey: ["byline-fields"], | ||
| queryFn: listBylineFields, | ||
| staleTime: 60 * 1000, |
There was a problem hiding this comment.
[low] 60s staleTime on the byline-fields list can produce silent admin-side errors. The schema admin page invalidates ["byline-fields"] after every mutation, so editing schema in this tab refreshes the bylines editor inline. But if an admin reorders or deletes a field from outside this tab (CLI, second browser tab, second admin), this tab keeps rendering the stale field set for up to 60s. The custom-field write path then either:
- silently sends a value for a deleted field →
EmDashValidationError("Unknown byline custom field …")→ toast, or - silently sends a stale select-choice that's been removed → 400.
Not blocking, and the deferred-work note in the PR description partly covers this. The fetch is cheap; consider staleTime: 0 plus refetchOnWindowFocus: true so cross-tab edits surface immediately. A user who reads through this comment might also reasonably wonder whether the registry's version counter could drive client-side invalidation — that would close the loop end-to-end, but feels out of scope for this PR.
|
Review posted (id 4400926269). Posted a review with three nitpick-level inline comments and a top-level note about the Findings:
(Apology in the body: the fork-review tooling left four "test"-bodied review stubs above the real review when I was probing which line numbers GitHub considered in-diff — those reviews cannot be deleted via the API once submitted.) |
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
|
/review |
|
Actually wait — if the delete is the last sibling, and Still worth a comment maybe. But I'm getting into super-nit territory. Let me focus on finalizing my review. Given the prior LGTM! |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
| const defs = await new BylineSchemaRegistry(db).listFields(); | ||
| holder.cached = defs; |
There was a problem hiding this comment.
The jsdoc says that it caches the promise not the resolved value, but we're awaiting it before assigning. It's better to cache the promise because, as the comment says, it allows us to coalesce concurrent requests to use a single db query.
What does this PR do?
Adds support for site-specific byline metadata — Twitter handle, pronouns,
company, localised job title, etc. — via a new
/byline-schemaadminscreen. Sites can register custom fields with their own type, validation,
and per-locale storage policy without editing code; values surface on
BylineSummary.customFieldsfor the frontend.Custom-field values can be set at both create and update time through
the same
customFieldsmap onPOSTandPUT /_emdash/api/admin/bylines.In the admin, registered fields render inline with Name, Bio, etc. — no
"Custom fields" section header — and are available in both the New byline
and Edit byline dialogs. The schema link sits at the top of the
Bylines page (admin-only) rather than the global sidebar so admins find
it in context.
Design choices worth flagging
_emdash_byline_fieldsis thesource of truth, with
options.byline_fields_versiondriving cacheinvalidation. Admins register fields through the admin UI; no code
edits required.
translatableflag. Translatable values are stored perlocale (one row per locale in the byline's
translation_group).Non-translatable values are stored once per
translation_groupandsurface on every locale variant. The flag is locked once values
reference the field — flipping would orphan rows in the wrong table.
urlscheme allowlist. URL fields requirehttp:orhttps:;javascript:/data:/mailto:/ftp:/file:reject. MirrorshttpUrlinapi/schemas/common.ts— custom URLs typically renderas
<a href>so this closes the XSS footgun.write and per-field writes roll back together on partial failure.
transactions, so a crash between the row insert and the field writes
leaves a partial byline that the API would otherwise refuse to
recover from. A retry POST is treated as completing the abandoned
create iff the full fixed-column payload, the translation-group
identity, and the existing custom-field subset all match the
incoming request. Anything else collapses to a standard
duplicate-slug
CONFLICT— recovery only fires when the retry isprovably the same request as the original.
options.byline_fields_versioncarries meaning in its parity — odd = mutation in flight (or
crashed), even = stable. The cache bypasses the global holder while
odd.
markVersionDirtyis parity-aware idempotent;markVersionCleanalways advances to a new even value so two concurrent mutators
can't collapse on the same key and pin a stale snapshot.
Idempotent-retry exits also run
markVersionClean— same code pathdoubles as crash recovery and false-clean recovery.
BylineSummarygains an optionalcustomFields: Record<string, CustomFieldValue>. Existingobject-literal consumers stay source-compatible — the property is
optional and runtime always returns
{}when no fields are registered.Builds on the bylines-i18n foundation from #1146.
What's deferred
requiredflag enforcement. The admin UI exposes a Requiredtoggle and the value persists on the field definition, but the
server accepts missing values and
nullfor any field. Needs adesign call on the enforcement model (reject on first set only?
reject on every save? coerce vs reject?). TODOs are in
BylineRepository.coerceFieldValueandBylineFieldEditor.parity-aware bookend has a residual race between two concurrent
markVersionCleancalls — bounded by the inter-clean duration(~ms). Schema mutations are admin-only and rare; acceptable for
this PR. A CAS-on-bump or dialect-specific row lock would close
it but raises dialect-divergence scope.
multiple conjunctive guards approximating "same request as before"
via state inspection. If similar D1 retry-safety problems recur on
other create endpoints, an explicit
Idempotency-Keyheader wouldreplace the apparatus with one lookup. Worth a Discussion before
the next D1 retry surface comes up.
translationOf-flavoured create recovery. The sibling-localeguard returns
CONFLICTbefore the recovery branch can fire ontranslation creates, so partial
translationOfretries fall throughto manual recovery via the edit flow. Uncommon enough that
reordering the guard wasn't justified for this PR.
Closes #1174
Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
Screen.Recording.2026-06-01.at.11.26.51.mov