Skip to content

fix(loader): seek taxonomy-filtered listings via a denormalized pivot#1962

Merged
ascorbic merged 2 commits into
emdash-cms:mainfrom
MA2153:fix/taxonomy-filter-pivot-seek
Jul 12, 2026
Merged

fix(loader): seek taxonomy-filtered listings via a denormalized pivot#1962
ascorbic merged 2 commits into
emdash-cms:mainfrom
MA2153:fix/taxonomy-filter-pivot-seek

Conversation

@MA2153

@MA2153 MA2153 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Taxonomy-filtered collection listings (getEmDashCollection("posts", { where: { category }, orderBy, limit })) used to apply the taxonomy filter as a correlated EXISTS on SELECT * FROM ec_<collection> … ORDER BY … LIMIT ?. The pivot table content_taxonomies carried only (collection, entry_id, taxonomy_id), so the filter (deleted_at/status/locale) and the ordering (published_at/created_at) could only be evaluated on the ec_* row. On stats-blind SQLite/D1 a selective term never fills the LIMIT, so the query walks the entire collection — a measured ~75,000 D1 rows read for a page returning one row (rows-read is the billed/throttled unit on D1).

This denormalizes the filter + sort columns onto content_taxonomies, mirrors ec_*'s composite sort indexes onto the pivot keyed by (taxonomy_id, collection, deleted_at, [locale,] <sort> DESC, entry_id DESC), and restructures the taxonomy-filtered listing to drive from the pivot: seek the term, walk a sort-ordered pivot index, let LIMIT short-circuit, and touch ec_* only by primary key to hydrate the page.

The pivot is advisory; ec_* is authoritative. D1 has no transactions, so the write-path re-stamp is a separate statement from the ec_* mutation and can be transiently stale. The outer ec_* join therefore re-checks deleted_at/status/locale on the joined row — a stale pivot can only cause a page to briefly under-fill (self-healing on the next request); it can never leak a deleted or wrong-status/locale row onto a public listing.

Implementation:

  • Schema: six nullable columns added to content_taxonomies (status, scheduled_at, deleted_at, locale, published_at, created_at). updated_at is deliberately not denormalized — it moves on every edit, so denormalizing it would force a pivot re-stamp on the common edit path; updated_at-sorted taxonomy listings take a bounded temp-sort path instead.
  • Migration 051 (forward-only): adds the columns (idempotent), backfills per-collection, then builds four composite sort indexes.
  • Read path (loader.ts): a caller-agnostic pivot-drive builder (parameterized on the deleted_at predicate and status condition, so it can also serve an admin trash/all-statuses shape). Both dialects run the identical query shape.
  • Write path: synchronous re-stamp at the existing data-layer choke points (ContentRepository, TaxonomyRepository) — no stray SQL.

Supersedes the closed #1852 (cached-count runtime planner — wrong layer). This is also the substrate the future taxonomy-subtree filter needs to perform well.

Follow-up (out of scope): idx_content_taxonomies_term (taxonomy_id) from migration 048 is now redundant — all four new indexes lead with taxonomy_id and serve a bare taxonomy_id seek as a prefix. Dropping it is a separate additive-discipline call (048 was a deliberate restore for #1701), flagged here so the write cost isn't paid twice.

Closes #1834

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)
  • 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 strings; this is a data-layer/loader change.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a: this is a performance/bug fix with an approved design spec, not a feature.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code). Code review pass also run.

Screenshots / test output

  • Full emdash core suite: 4675 passed, 3 skipped. Lint: 0 diagnostics. Typecheck clean. Formatted.
  • Tests added: tests/unit/loader-taxonomy-pivot.test.ts (describeEachDialect: parity across selective/broad/OR-within-taxonomy/multi-term-AND/sort orders/locale/status; stale-pivot correctness under non-atomic writes; re-stamp sync; keyset pagination incl. the GROUP-BY/HAVING multi-group path; byline-in-pivot; admin trash/all-statuses builder shapes) and tests/integration/loader-taxonomy-pivot-plan.test.ts (SQLite EXPLAIN QUERY PLAN: asserts the correct pivot index per case, no ec_* full scan, and no temp B-tree for the indexed early-LIMIT).
  • CI note: EMDASH_TEST_PG was not set locally, so only the SQLite dialect executed. The raw SQL (row-value UPDATE … SET (cols) = (SELECT …), MAX()/GROUP BY/HAVING, CTE) was reasoned through as valid Postgres but not exercised — please confirm the describeEachDialect suite runs against real Postgres in CI.
  • Query-counts snapshot+1 query on the taxonomy-filtered routes (/category/*, /tag/*), on both cold and warm. This is the pivot-drive's up-front term resolution (resolveTermGroups): the seek needs literal translation_group values, because on a stats-blind planner an inlined taxonomy_id IN (SELECT …) is treated as multi-valued and forces a temp-sort over the whole term — defeating the early-LIMIT seek (confirmed via EXPLAIN QUERY PLAN). So the values must be resolved in a separate statement. It's cheap in rows-read (rides idx_taxonomies_name_locale, touches one taxonomy's terms), but currently uncached, so it lands on cold and warm. Caching that resolution to reclaim the warm cost is deferred — the obvious reuse (getTaxonomyTerms) regresses localized filtering (Collection where taxonomy filter joins on t.id (term id) instead of translation_group, so it only matches default-locale term slugs #1480), so the right shape is an open call tracked in Taxonomy-filtered listings: how should we cache the term→group resolution? (follow-up to #1962) #1966. Net rows-read still collapses by ~4 orders of magnitude on a selective term page, which is the point of this PR.

🤖 Generated with Claude Code

Denormalize the filter+sort columns onto content_taxonomies, mirror the
ec_* sort indexes onto the pivot, and drive taxonomy-filtered listings
from the pivot with an authoritative ec_* re-check. Fixes full-collection
scans (~75k D1 rows read for a one-row page) on selective terms (emdash-cms#1834).

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

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3adf276

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

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@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 Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/XL labels Jul 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,228 lines across 13 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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 3adf276

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label Jul 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Query-count snapshot changes

8 routes changed, total Δ +8 queries.

SQLite

Route Before After Δ
GET /category/development (cold) 11 12 +1
GET /category/development (warm) 10 11 +1
GET /tag/webdev (cold) 10 11 +1
GET /tag/webdev (warm) 10 11 +1

D1

Route Before After Δ
GET /category/development (cold) 22 23 +1
GET /category/development (warm) 10 11 +1
GET /tag/webdev (cold) 22 23 +1
GET /tag/webdev (warm) 10 11 +1

Comparing snapshot files between base and head. Updated automatically on each push.

@MA2153
MA2153 marked this pull request as draft July 11, 2026 18:17

@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 is the right change for #1834. Taxonomy-filtered listings were indeed doing a full walk of ec_* with a per-row correlated EXISTS; denormalizing the filter/sort columns onto content_taxonomies and driving the query from a covering pivot index is an idiomatic fix for D1's stats-blind planner. The read path keeps ec_* authoritative by re-checking deleted_at/status/locale on the joined row, which matches the documented non-atomic-write staleness semantics.

I traced:

  • Schema & migration (051): six nullable denormalized columns added idempotently, backfilled per-collection with a safe correlated UPDATE, and four composite indexes built after the backfill. The down path is present for tests.
  • Write path: ContentRepository.restampEntryPivot is awaited after every ec_* mutation that moves a denormalized column (status, published/scheduled dates, deleted_at). TaxonomyRepository stamps the pivot at every insert site (attachToEntry, setTermsForEntry, copyEntryTerms). Production insert paths all go through these helpers.
  • Read path: buildTaxonomyPivotQuery produces an indexed sort for published_at/created_at, a bounded temp-sort for other orders, handles OR-within-taxonomy (GROUP BY/MAX/cursor in HAVING), multi-taxonomy AND via residual EXISTS, and carries byline filters in the CTE. Inputs are parameterized or validated (getTableName/validateIdentifier), and no user strings reach sql.raw.
  • Tests: new unit tests cover parity, locale/status filtering, stale-pivot guards, re-stamp sync, keyset pagination, byline AND, and admin-shape builders; a new integration plan test asserts the expected SQLite index usage.

No blocking bugs or AGENTS.md violations. One note, not a code finding: the PR description references docs/superpowers/specs/2026-07-11-taxonomy-filter-pivot-seek-design.md, but that file is not in the tree.

LGTM.

@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.

@github-actions github-actions Bot removed the review/needs-review No maintainer or bot review yet label Jul 11, 2026
@MA2153
MA2153 marked this pull request as ready for review July 11, 2026 21:13
@github-actions github-actions Bot added the review/approved Approved; no new commits since label Jul 11, 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 the same pivot-drive taxonomy listing change. The approach remains sound and idiomatic for #1834: denormalizing filter/sort columns onto content_taxonomies, mirroring the composite sort indexes, and driving the listing from a covering pivot index lets D1/SQLite short-circuit on LIMIT while keeping ec_* authoritative via the outer re-check.

I re-read the new migration, loader builder, write-path re-stamps, and tests. No new blocking bugs or AGENTS.md violations surfaced:

  • Migration 051 is idempotent on column adds and index creation, uses validated identifiers, and the row-value correlated UPDATE is valid on both SQLite and Postgres.
  • ContentRepository.update correctly avoids re-stamping on plain data edits (no updated_at denormalization), and re-stamps wherever status, published_at, or scheduled_at can change, plus soft-delete/restore/publish/schedule/unschedule.
  • TaxonomyRepository stamps the pivot at every insert/assignment site.
  • The read path re-checks deleted_at, status, and locale on the joined ec_* row, matching the documented non-atomic-write semantics.
  • Tests cover parity, stale-pivot guards, locale/status filtering, keyset pagination, and query-plan shape.

The only note from my prior review still stands, and it is non-blocking: the PR description references docs/superpowers/specs/2026-07-11-taxonomy-filter-pivot-seek-design.md, but that file is not present in the tree. If the spec is meant to land with the change, it should be added; otherwise the PR description should be updated to remove the reference.

LGTM.

@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.

Looks solid. Thanks

@ascorbic
ascorbic merged commit c24b7d3 into emdash-cms:main Jul 12, 2026
54 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 12, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…emdash-cms#1962)

* fix(loader): seek taxonomy-filtered listings via a denormalized pivot

Denormalize the filter+sort columns onto content_taxonomies, mirror the
ec_* sort indexes onto the pivot, and drive taxonomy-filtered listings
from the pivot with an authoritative ec_* re-check. Fixes full-collection
scans (~75k D1 rows read for a one-row page) on selective terms (emdash-cms#1834).

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

* ci: update query-count snapshots

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: signed overlap query-count changed PR diff modifies query-count snapshot files review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Taxonomy-filtered collection queries do a full table scan (75k+ D1 row reads) on selective terms

2 participants