feat: filter the admin content list by byline - #2312
Conversation
🦋 Changeset detectedLatest commit: 6c8fe03 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 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 |
Scope checkThis PR changes 674 lines across 12 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
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. |
Adds byline filtering alongside the existing status, author, and date filters. Selecting several bylines matches entries credited to any of them; "No byline assigned" matches entries with no credit. Credits inferred from an entry's author (rendered when an entry has no explicit credit) are excluded unless opted into, so the filter matches assigned bylines by default. No migration is required. The UNIQUE(collection_slug, content_id, byline_id) index from migration 031 covers every filter shape: EXPLAIN QUERY PLAN shows a covering seek for the include, exclude, and no-byline probes while the outer query keeps its sort-ordered composite index, so LIMIT still short-circuits without a temp B-tree. Correlating EXISTS from the content table is what makes that hold — driving from the pivot side cannot use the index for the byline and forces a temp sort — hence the note in applyBylineFilter. Filter values are translation_groups (what the junction has stored since migration 040), so a selection matches a byline across every locale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Temporary tooling for exercising the byline filter by hand. Not intended to ship with the feature -- revert this commit before the PR goes up. Adds a "Set byline" picker to the existing bulk-selection toolbar. The picked bylines replace each selected entry's credit set rather than merging into it: list items hydrate credits with strict locale matching, so an entry whose byline has no row in the entry's locale comes back with an empty `bylines` array, and a client-side merge silently drops those credits on write. Requests fan out through runBulkAction like the other bulk actions, so failed ids stay selected for a retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit 28101af.
The inferred-byline branch of the content-list filter matched on `author_id` alone, against a set of users resolved from every locale row of the selected bylines. Byline hydration is strict per locale, so an entry whose author owns a byline with no row at the list's locale renders as uncredited while still matching a filter on that byline -- and matched "No byline" at the same time, since that branch did correlate on locale. Resolve the credit the same way the list renders it: a correlated EXISTS on `_emdash_bylines` scoped to the locale the list is showing, falling back to each entry's own locale when the list spans locales. This drops the pre-resolution query the handler ran for `includeInferredBylines`, so the opt-in no longer costs an extra round-trip. Also pluralize the selected-byline count through Lingui's `plural` rather than a bare interpolation, which only reads correctly in languages with a single plural form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28101af to
b9d991f
Compare
There was a problem hiding this comment.
This PR adds a byline filter to the admin content list. The approach is sound: it filters on the same translation_group values stored in _emdash_content_bylines, uses a correlated EXISTS so the outer query keeps its sort-ordered index, and resolves inferred credits at the list locale so the filter agrees with what the list actually renders. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, the empty-selection guard, and total). No new queries are added to logged-out routes, and the changeset covers the affected packages.
The main blocker is policy, not code: AGENTS.md requires maintainer-approved Discussion for new features, and the PR itself notes that Discussion #2347 is still pending approval and the PR is a draft. That approval should land before merge.
There is one AGENTS.md convention issue: an over-narrative doc comment in the repository layer that justifies the SQL shape, describes a rejected alternative, and references migration numbers. Comments should state non-obvious invariants concisely; everything else belongs in the PR description or commit message. Fixing that is the only code-level change I'd ask for.
Drop the rejected-alternative narrative and the migration reference; keep why the EXISTS correlates from the content table and why "none" tests the junction rather than primary_byline_id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach is sound: it extends the existing content-list filter set, uses the same translation_group values stored in _emdash_content_bylines, resolves inferred credits at the list locale so the filter matches what the list renders, and keeps the query shaped as a correlated EXISTS from the content table so the outer sort index survives. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard), and no new queries are added to any logged-out route.
The over-narrative repository comment flagged in the prior review has been trimmed to just the two invariants a future reader needs, which fixes that AGENTS.md issue.
Remaining item: one minor AGENTS.md convention note below on a justification-style sentence in the new ContentBylineFilter docstring.
Reminder: the PR itself notes that Discussion #2347 is still awaiting maintainer approval, and the feature checklist item is intentionally unticked. That policy approval should land before merge; the code-level review does not override that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach is sound and sits well in EmDash's architecture: it extends the existing server-side content-list filters, uses the same translation_group values stored in _emdash_content_bylines, resolves inferred credits at the list locale so filtering matches what the list renders, and keeps the query shaped as a correlated EXISTS from the content table so the outer sort index survives. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard), and no new queries are added to logged-out routes.
The two AGENTS.md comment-discipline notes from the prior review have been addressed: the repository comment on applyBylineFilter is now limited to the two invariants a reader needs, and the ContentBylineFilter type docstring no longer contains a justification sentence.
One minor comment-discipline item remains in the new BylineFilter component docstring, which still narrates the rejected exhaustive-list alternative rather than only stating what the component does. Otherwise the code is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach remains sound: it extends the existing server-side content-list filters, matches on translation_group values stored in _emdash_content_bylines, and resolves inferred credits at the list locale so filtering agrees with what the list renders. The query is shaped as a correlated EXISTS from the content table, the IN clause is bounded by schema validation, and the 12 integration tests cover OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard. No new queries are added to logged-out routes.
The two comment-discipline items from the last review (applyBylineFilter repository comment and ContentBylineFilter type docstring) are now trimmed to behavior-only statements. One minor AGENTS.md comment-discipline item remains in a docstring the PR edits: a stale GitHub issue reference in FilterBar. That's the only code-level note remaining.
Per the PR description, Discussion #2347 is still unapproved, so merging remains gated on maintainer direction there. Absent that process gate, this is close to an LGTM.
Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
This is the right change for the problem described in Discussion #2347 (still pending approval). It extends the admin content list with a server-side byline filter, matches on translation_group values stored in _emdash_content_bylines, and resolves inferred credits at the list locale so filtering agrees with what the list renders. The correlated-EXISTS shape preserves the outer sort index, the 25-id schema cap avoids D1 bind-parameter blow-up, inferred/explicit semantics are handled consistently, and the 12 integration tests cover OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, and totals. No new queries are added to logged-out routes.
I re-read the diff, the full changed files, and relevant call sites/siblings. The previous comment-discipline finding in FilterBar is fixed. One stale GitHub issue reference remains, now in router.tsx directly above the newly-extended filter-state block. That’s the only code-level note. Fix it and this is a code-level LGTM. Merge remains gated on maintainer direction in Discussion #2347, not on the code review.
Findings
-
[needs fixing]
packages/admin/src/router.tsx:352-353Comment still references a GitHub issue (
#1288) in the filter-state block the PR extends. AGENTS.md says comments are for future readers, not PR/issue references. TheFilterBardocstring already dropped this reference; remove it here too.// Filter state. All are part of the query key so changing any of // them restarts the cursor chain from a filtered first page.
|
Ok I am done with the bot nitpicks. |
ascorbic
left a comment
There was a problem hiding this comment.
Thanks. Nearly there: just one correctness issue in the sql
The credit EXISTS matched a junction row by translation_group alone, but a credit only renders where that group has a byline row at the list's locale. Junction rows are copied to every translation of an entry, so a group that exists only in `en` still matched `bylines=[group]` on the `fr` list, where the entry renders uncredited — and was excluded from "no byline" at the same time. The credit test now repeats the join `getContentBylinesMany` makes, with the same list-locale / entry-locale fallback as the inferred branch. The author fallback keeps a separate, deliberately locale-agnostic junction probe: hydration suppresses inference on the presence of any credit, not on one that renders here, so an entry whose explicit credit fails to resolve stays uncredited rather than falling through to its author. Adds a query-plan test covering all four filter shapes: every probe is still an indexed seek and no shape sorts in a temp b-tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
This is the right change for Discussion #2347: it adds a server-side byline filter to the admin content list, matches on translation_group values, and correctly resolves both explicit and inferred credits at the list locale so filtering agrees with rendering. The correlated-EXISTS shape preserves the outer sort index, the 25-id cap avoids D1 bind-parameter blow-up, and the 14 integration tests plus 5 plan tests cover the important edge cases (OR matching, no-byline, inferred opt-in/out, locale-scoped credits, status composition, totals). No new queries are added to logged-out routes, and route-level authorization is unchanged.
I re-read the diff, the full changed files, the route handlers, the API schema, the repository SQL, and the tests. One prior code-level note persists: the #1288 GitHub issue reference in router.tsx above the filter-state block is still there, despite the FilterBar docstring having dropped the same reference. Fixing that makes this a code-level LGTM. I also have one mild suggestion about the query-plan test pinning specific index names, which trades some fragility for the author's stated goal of preventing silent plan drift.
Findings
-
[needs fixing]
packages/admin/src/router.tsx:352-353The comment still references a GitHub issue (
#1288) in the filter-state block this PR extends. AGENTS.md says comments are for future readers of the code, not issue/PR references, and the siblingFilterBardocstring already dropped this reference in an earlier round. Remove the issue number.// Filter state. All are part of the query key so changing any of // them restarts the cursor chain from a filtered first page. -
[suggestion]
packages/core/tests/integration/content/content-list-byline-filter-plan.test.ts:90-91The plan tests assert specific SQLite index names such as
sqlite_autoindex__emdash_content_bylines_2. Those names are derived from the physical schema, so an intentional migration rename or index replacement that keeps the same query shape would still fail the test. The behavior checks (not.toContain("SCAN ..."),not.toContain("TEMP B-TREE")) already guard against the real regression (full scans / temp sorts). Consider keeping only the behavior assertions so the test fails on plan regressions without pinning schema-derived identifiers.
Comments are for future readers of the code; the issue number is narrative that belongs in the commit and PR description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
This is the right change for Discussion #2347. The PR adds a server-side byline filter to the admin content list that matches on translation_group, supports OR selection plus a "no byline" mode, and correctly separates inferred from explicit credits. The correlated (NOT) EXISTS shape preserves the outer sort index, locale scoping keeps filtering consistent with how entries are rendered, and the 25-id cap avoids D1 bind-parameter blow-up. The implementation follows the existing filter plumbing, is gated behind unchanged admin permissions (content:read, bylines:read), and adds no queries to logged-out routes.
I re-read the diff, the full changed files, the route handlers, the API schema, the repository SQL, the byline repository, and the tests. The only prior code-level finding is fixed: the #1288 GitHub issue reference in router.tsx is gone. The locale-scoped explicit/inferred credit tests reproduce the subtle disagreements described in the PR and pass against the current logic.
One non-blocking follow-up persists: the plan test pins the auto-generated SQLite index name sqlite_autoindex__emdash_content_bylines_2, which the author already flagged as fragile because adding another unique constraint to the table can renumber it. I’ve left a suggestion to replace that assertion with a regex on the indexed column tuple, which keeps the meaningful invariant without coupling the test to SQLite’s autoindex numbering. The explicit idx_* assertions are intentionally named in migrations and are fine to keep.
With the documented limitation noted (full-scan cost on very large collections for sparse byline matches, accepted as an authenticated-admin trade-off), this is a code-level LGTM.
|
|
||
| // Junction seek on (collection_slug, content_id, byline_id) — the unique | ||
| // from migration 031 — then the byline group at the list's locale. | ||
| expect(plan).toContain("sqlite_autoindex__emdash_content_bylines_2"); |
There was a problem hiding this comment.
[suggestion] This assertion pins the auto-generated SQLite index name for the UNIQUE(collection_slug, content_id, byline_id) constraint. That name is positional: if an unrelated migration adds another unique constraint to _emdash_content_bylines, sqlite_autoindex__emdash_content_bylines_2 can become _3 and this test will fail even though the plan shape is still correct. Assert the seek columns instead so the test stays invariant under autoindex renumbering.
| expect(plan).toContain("sqlite_autoindex__emdash_content_bylines_2"); | |
| expect(plan).toMatch( | |
| /SEARCH cb USING INDEX \S+ \(collection_slug=\? AND content_id=\? AND byline_id=\?\)/, | |
| ); |
What does this PR do?
Adds byline filtering to the admin content list, alongside the existing status, author, and date filters. Selecting one or more bylines matches entries credited to any of them (OR); a No byline assigned option matches entries with no credit at all.
Bylines are searched server-side in the picker rather than listed exhaustively, so the filter works across the whole byline directory rather than one page.
Inferred bylines are excluded by default. An entry with no explicit credit still renders the byline linked to its author (see
hydrateBylinesMany), but filtering usually means "who is credited", not "whose name happens to show". A single Include inferred bylines switch opts into the wider behaviour, and it widens consistently: with it on, "No byline assigned" means nothing is rendered, so entries whose author resolves to a byline drop out too.Filter values are
translation_groups — what_emdash_content_bylines.byline_idhas stored since migration 040 — so a selection matches a byline across every locale it exists in.Proposed in Discussion #2347 (Ideas), now maintainer-approved.
Discussion: #2347
Credits resolve at the list's locale
Byline hydration is strict per locale: a credit renders only where its
translation_grouphas a byline row at the locale the list is showing. Both branches of the filter originally ignored that, in the same way and with the same symptom — an entry the list renders as uncredited matching a filter on a byline, and matching No byline assigned at the same time. The two branches were fixed one review round apart:author_idalone, against a set of users resolved from every locale row of the selected bylines. An entry whose author owns a byline with no row at the list's locale displays as uncredited but still matched a filter on it.pullrequestreview-4890821647). The junction stores a group andcopyContentBylinespropagates junction rows to every translation of an entry, so a group that exists only inenstill matchedbylines=[group]on thefrlist — where the credit renders nothing.Both now resolve the credit the way the list renders it: the explicit branch repeats the
_emdash_bylinesjoingetContentBylinesManymakes, the inferred branch a correlatedEXISTSon the same table, each scoped to the list's locale and falling back to each entry's own when the list spans locales. Byline translations start life with a nulluser_id, so a group translated into a locale but not re-linked correctly resolves to no credit.One asymmetry is deliberate and load-bearing: whether a credit renders is locale-scoped, whether one exists is not.
hydrateBylinesManysuppresses the author fallback on the presence of a junction row, not on one that resolves at this locale, so the inference gate keeps a separate locale-agnostic probe. Without it, an entry whose explicit credit fails to resolve would fall through to its author and match a filter on a byline the list doesn't show against it.The inferred fix also removes a query: the handler no longer pre-resolves author ids for
includeInferredBylines, so the opt-in costs no extra round-trip.No migration required
The plan is now pinned by a committed test (
content-list-byline-filter-plan.test.ts) rather than measured by hand, so it can't drift silently — the assertions runEXPLAIN QUERY PLANover the SQL the repository actually emits, against a freshly-migrated stats-blind DB (noANALYZE, matching D1). TheUNIQUE(collection_slug, content_id, byline_id)constraint from migration 031 already creates an index of exactly the right shape, and the locale scoping lands on the existing byline uniques:Every probe is an indexed seek, and the outer query keeps its sort-ordered composite index in all four shapes, so
LIMITstill short-circuits with noUSE TEMP B-TREE FOR ORDER BY. Scoping the explicit branch to the locale added a join inside theEXISTSand no round-trips: the byline row is reached through the(translation_group, locale)unique, covering in three of the four shapes.Two consequences are worth spelling out, since neither is visible from the query alone. The full reasoning lives here; the code comment states only the invariants a future reader needs:
EXISTSshape matters. Driving from the pivot side (FROM _emdash_content_bylines JOIN ec_*) cannot use that index for the byline and addsUSE TEMP B-TREE FOR ORDER BY. Written as anEXISTSfrom the content table, no new index is needed; written the other way, no index rescues it.primary_byline_id. The two agree — both junction write paths stamp the column in the same call — but not atomically (D1 has no transactions), so the junction stays authoritative, mirroring how migration 051 treats the denormalized taxonomy columns as advisory and re-checks on read.Known limitation, deliberately not addressed: the
EXISTSplan walks the collection's sort index and probes per row, so a byline matching very few entries in a very large collection reads a lot before fillingLIMIT— the shape #1834/migration 051 fixed for taxonomies. Making that seek-optimal needs denormalization, i.e. a migration. This is the authenticated admin list rather than the logged-out hot path, so it didn't seem worth paying now. Happy to revisit.No queries were added to any logged-out route.
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
Rebased onto
main(currently215f36eb); the only conflict was an import block inContentList.tsxthat #2340 and this branch both added to, resolved by keeping both.Verified in the browser against
demos/simple: the filter renders in the existing filter bar as an "All bylines" dropdown containing a search box, the exclusive "No byline assigned" checkbox, the byline list, and the "Include inferred bylines" switch.Also checked in Arabic per the RTL guidance —
document.documentElement.dirisrtl, and the popover, checkboxes, switch, and caret all mirror correctly with no broken directionality. The selected-byline count goes through Lingui'spluralrather than a bare interpolation, which only reads correctly in languages with one plural form. (New strings render in English until the extraction workflow picks them up on merge; nomessages.pochanges are included here.)Screenshots can be attached on request — omitted here since I can't upload to GitHub's CDN from the CLI.
Tests
14 integration tests (
content-list-byline-filter.test.ts), run against both dialects viadescribeEachDialect, covering single/multi-byline OR matching, the no-byline filter, inferred credits on and off, an explicit credit suppressing author inference, credits resolving at the locale the list is scoped to — explicit and inferred, composition with the status filter, andtotalreflecting the filter.Plus 5 query-plan tests (
content-list-byline-filter-plan.test.ts, SQLite-only) asserting the plan quoted above: an indexed seek for every probe in all four filter shapes, and noUSE TEMP B-TREE FOR ORDER BY.Four of them caught real bugs during development:
eb.val(false), which better-sqlite3 refuses to bind (SQLite3 can only bind numbers, strings, bigints, buffers, and null). It now emits a literal1 = 0predicate instead.pullrequestreview-4890821647: anfrentry credited to anen-only byline group matchedbylines=[group]while rendering uncredited.Behaviour verified end-to-end against
demos/simple, filtering a 16-entry collection:totalbylines=<A>bylines=<A>,<B>bylines=nonebylines=<unknown-id>bylines=<A>&status=published6 + 10 = 16 — the include and no-byline filters partition the collection exactly.
Wire contract:
includeInferredBylines=1/true/0/falseincludeInferredBylines=yesVALIDATION_ERRORbylines=(empty)VALIDATION_ERRORThe 25-id cap keeps the
IN (...)clause clear of D1's bound-parameter ceiling once the rest of the list query's placeholders are counted.Review responses
pullrequestreview-4867919957Over-narrative doc comment on
applyBylineFilter— fixed in10f415d4. Agreed, and taken essentially as suggested. The comment now states only the two invariants: that theEXISTScorrelates from the content table so the outer sort index survives, and thatmode: "none"tests the junction rather thanprimary_byline_idbecause the two aren't written atomically. The rejected pivot-side shape, theEXPLAIN QUERY PLANoutput, and the migration 051 precedent stay in this description, where they don't go stale in the source tree. The paragraph above that introduced them has been reworded, since it claimed those details were recorded in comments.Discussion approval. Correct — #2347 is still unapproved and the Discussion checklist item is unticked, so this shouldn't merge until a maintainer weighs in on the direction. Earlier revisions of this description said the PR "stays a draft"; that was inaccurate, it is open for review rather than in draft state. Corrected above.
pullrequestreview-4868375437Justification sentence in the
ContentBylineFilterdocstring — fixed in763e417b. Taken as suggested; the docstring now ends at whatincludeInferreddoes, and the rationale for the default lives in this description.A request on review process. This is the second review that confirms the approach, confirms the tests, confirms the query counts, and then holds approval for a single sentence in a comment. Both sentences were in the diff at the time of the first review — the second one wasn't introduced by the fix for the first. Reviewing one comment per round turns a two-line docs change into a multi-day round-trip for no added signal.
Please review the whole diff each time and give every objection in one go, ordered by severity, including the ones you consider minor. If a nit is genuinely not worth blocking on, raise it as a non-blocking note and approve rather than withholding approval for it. Absent a new finding of substance, this should be approved on the next pass.
Discussion approval stands as noted: #2347 is still unapproved, the checklist item stays unticked, and this shouldn't merge until a maintainer weighs in on the direction. That's a merge gate, not a review gate — it isn't a reason to withhold code-level approval.
pullrequestreview-4872763040Rationale paragraph in the
BylineFiltercomponent docstring — fixed in6d39c21a. Taken as suggested; the docstring now states only what the component does.On the previous round's request, which wasn't followed. That comment has been in the diff unchanged since
a3a13b52on 31 July — the original commit of this branch. It was there forpullrequestreview-4867919957, forpullrequestreview-4868375437, and for this one. Same for the two comments raised before it: every one of the three was present in the first version reviewed, and none was introduced by a fix for an earlier round.So three review rounds have produced three separate single-nit findings of the same class, from the same unchanged files, each one held as the reason not to approve. The previous round asked explicitly for all objections in one pass ordered by severity. That request was acknowledged in neither the review body nor its scope.
Concretely, for the next pass: re-read every file in the diff, not just the ones touched since the last review, and list every remaining objection at once — including ones you'd rate minor. If nothing of substance remains, approve and attach any residual nits as non-blocking notes. Another round that surfaces one more pre-existing comment nit and withholds approval on it is not a useful review, and I'll ask a maintainer to take the code-level pass instead.
Discussion approval is unchanged: #2347 is still unapproved and this shouldn't merge until a maintainer weighs in on the direction. That remains a merge gate, not a code-review gate. (Superseded — see the round below.)
pullrequestreview-4890821647The explicit-credit
EXISTSwasn't locale-scoped — confirmed and fixed in6c8fe031. The finding is correct as stated, including the mirroredmode: "none"gap. Reproduced first as a failing test: anfrentry credited to a byline group that exists only in the default locale renders uncredited viahydrateBylinesMany, matchedbylines=[group], and was excluded from No byline assigned — the same entry under two mutually exclusive filters, which is exactly the disagreement the inferred branch was fixed for one round earlier. I'd scoped that branch and left this one, on the wrong assumption that the junction storing a group made it locale-independent;copyContentBylinespropagating rows to every translation is what makes it not.Fixed as suggested: the credit test now joins
_emdash_bylinesonb.translation_group = cb.byline_idwith the samefilter.locale/ entry-locale fallbackauthorHasBylineuses — the joingetContentBylinesManyalready makes, so the filter and the render path derive the credit identically.One follow-on the suggestion doesn't cover. Locale-scoping the credit test alone introduces a second disagreement, in the inference gate.
hydrateBylinesManysuppresses the author fallback on the presence of any junction row (viaprimaryBylineId), not on one that resolves at this locale — so an entry whose explicit credit renders nothing atfrstays uncredited rather than falling through to its author. With a single locale-scoped predicate serving both roles, that entry would have matched a filter on its author's byline and dropped out of "no byline". The two roles are now separate predicates:creditRenders(locale-scoped, decides what matches) andhasExplicitCredit(locale-agnostic, decides whether inference applies at all). Both directions are covered by the new test.Plan re-measured, and now pinned. The added join costs no round-trip and no migration — the byline row is reached through the existing
(translation_group, locale)unique, covering in three of the four shapes. The plan output above is refreshed, and since the shape changed under me twice I've committed it as assertions (content-list-byline-filter-plan.test.ts) rather than re-pasting hand-runEXPLAINinto this description: all four filter shapes, every probe an indexed seek, noUSE TEMP B-TREE FOR ORDER BY.On review process. This round found a real bug that the previous three didn't, in a file all of them had in scope — that's the review the earlier rounds were asking for, and the earlier request is answered. Withdrawn, with thanks.
Discussion approval — resolved. #2347 is now maintainer-approved; the checklist item is ticked and the merge gate is lifted.
pullrequestreview-4915346586Issue reference in the
router.tsxfilter-state comment — fixed in848c726e. Taken as suggested; the comment now states only that the filter state is part of the query key. Missed when the same reference was dropped from theFilterBardocstring.#1288also appears twice incontent-list-filters.test.ts— in a header comment and in thedescribeEachDialectname. That file isn't in this diff, so removing them here would be a drive-by; left for whoever next touches it.On pinning index names in the plan test — keeping them. The suggestion is that
not.toContain("SCAN ...")andnot.toContain("TEMP B-TREE")already catch the real regression, so the named-index assertions only add fragility. They catch a different regression, though. The failure this test exists for is the query silently moving to a different but still indexed path — the pivot-side shape described above seeks too, and the locale join changed the shape under me twice while this branch was open. Behaviour-only assertions pass through all of that; the whole point of committing the plan was that hand-runEXPLAINoutput in a description goes stale without anything failing.The fragility objection does land on one line:
sqlite_autoindex__emdash_content_bylines_2is positional on migration 031'sUNIQUE(collection_slug, content_id, byline_id)rather than a name anyone chose, so an unrelated constraint added to that table renumbers it. That's a real trap and I'll take a fix for it if you have one that keeps the assertion meaningful. The twoidx_*names are explicit in their migrations — renaming one is an intentional act, and a test that has to be updated alongside it is working as intended, not fragile.Everything else — approach,
EXISTSshape, 25-id cap, locale resolution, test coverage, no new logged-out queries — is confirmed as read.