Skip to content

fix: dashboard 500 on D1 with many title-bearing collections (#895)#896

Open
cristianuibar wants to merge 1 commit intoemdash-cms:mainfrom
cristianuibar:fix/dashboard-d1-compound-select
Open

fix: dashboard 500 on D1 with many title-bearing collections (#895)#896
cristianuibar wants to merge 1 commit intoemdash-cms:mainfrom
cristianuibar:fix/dashboard-d1-compound-select

Conversation

@cristianuibar
Copy link
Copy Markdown

What does this PR do?

Fixes the GET /_emdash/api/dashboard 500 error on Cloudflare D1 (and the miniflare D1 emulator) when a project has enough title-bearing collections. The admin dashboard fails to render for every authenticated user once the threshold is crossed (reproduced at 9 arms in the issue).

Root cause: fetchRecentItems in packages/core/src/api/handlers/dashboard.ts composes a single chained UNION ALL across every title-bearing collection and wraps it in an outer SELECT * FROM (combined) ORDER BY updated_at DESC LIMIT 10. D1 enforces a SQLITE_LIMIT_COMPOUND_SELECT cap that is tighter than upstream SQLite's default of 500, so the query fails on D1 even though the same SQL runs fine against better-sqlite3. This is a 0.7.0 → 0.8.0 regression and is unchanged in 0.9.0.

Fix: Replace the chained UNION ALL with N per-collection queries fired in parallel via Promise.all, then merge / sort / slice in JS. Equivalent semantics, no schema changes, no D1-specific branching, and at most N * 10 rows materialized before the final slice(0, 10). Per-arm sql.lit(label) interpolation is preserved (kept on the outer side of each per-collection query, identical to the previous arm shape).

This patch was developed and validated in production at https://github.com/cristianuibar/wishday-web (12 collections, 9 title-bearing) by running pnpm patch emdash@0.8.0 against the affected file. The dashboard has rendered cleanly under that patch through staging deploys; this PR upstreams the same change.

Closes #895

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 (no new diagnostics introduced; pre-existing main-branch warnings unchanged)
  • pnpm test passes (or targeted tests for my change) — tests/unit/api/dashboard-handlers.test.ts 11/11 pass; the 8 pre-existing failures in vite-config.test.ts, wordpress-slug-sanitization.test.ts, and wp-prepare-invalidate.test.ts reproduce on origin/main and are unrelated to this 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). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. N/A — no admin UI strings touched.
  • I have added a changeset (if this PR changes a published package) — .changeset/fix-dashboard-d1-compound-select.md, emdash: patch
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... N/A — bug fix.

Test notes

The added regression test (merges recent items across many title-bearing collections (#895)) creates 12 collections each with a title field and one entry, then asserts the merged top-10 contract: count = 10, ordered by updated_at desc, drawn across all collections.

Caveat — the original D1 SQLITE_LIMIT_COMPOUND_SELECT cap cannot be reproduced under better-sqlite3 (its compound-SELECT limit is upstream SQLite's default of 500), so this test will not fail against the previous UNION ALL code path. It exists as a behavioral regression guard for the per-collection merge contract introduced by this fix and would catch a future revert. The actual 500 was reproduced and fixed against live Cloudflare D1 in https://github.com/cristianuibar/wishday-web via pnpm patch; the patch shipped here is equivalent.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7 (1M context) via Claude Code

Screenshots / test output

$ cd packages/core && pnpm exec vitest run tests/unit/api/dashboard-handlers.test.ts
 ✓ tests/unit/api/dashboard-handlers.test.ts (11 tests) 463ms

 Test Files  1 passed (1)
      Tests  11 passed (11)

Replace the chained UNION ALL composition in fetchRecentItems with N
per-collection queries fired in parallel and merged in JS. The previous
single-statement composition trips D1's SQLITE_LIMIT_COMPOUND_SELECT
cap once enough title-bearing collections are present, returning a 500
on every dashboard load. better-sqlite3's higher cap masked the bug
locally.

Adds a regression test covering the merged top-10 contract across 12
title-bearing collections.

Closes emdash-cms#895
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 2, 2026

🦋 Changeset detected

Latest commit: 05717b8

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

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-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
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@cristianuibar
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request May 2, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 3, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 05717b8

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 3, 2026

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Fix is correct, minimal, and well-scoped. Replacing the chained UNION ALL with N parallel per-collection queries side-steps D1's SQLITE_LIMIT_COMPOUND_SELECT cap without changing semantics: each arm preserves the same titleExpr, the same sql.lit interpolation for collection/collection_label, and the final merge/sort/slice yields equivalent output. The round-trip count is now O(N), but the dashboard already does an O(N) parallel fan-out for getStats immediately above, so this isn't a new shape.

Non-blocking nits

Stale doc comments still reference UNION ALL — both should be updated since the implementation no longer uses it:

  • packages/core/src/api/handlers/dashboard.ts:105 — the RecentItemRow interface comment says "Raw row shape from the UNION ALL query". Suggest: /** Raw row shape from per-collection queries — all snake_case. */
  • packages/core/src/api/handlers/dashboard.ts:117-126 — the fetchRecentItems JSDoc says "Uses UNION ALL over each ec_* table". Suggest something like:
    /**
     * Fetch the 10 most recently updated items across all collections.
     *
     * Issues one query per `ec_*` table in parallel and merges in JS.
     * A single `UNION ALL` across many collections trips D1's
     * `SQLITE_LIMIT_COMPOUND_SELECT` cap (#895). The query is safe because
     * collection slugs come from the system table and are validated.
     *
     * `title` is not a standard column — it's a user-defined field. We query
     * `_emdash_fields` to discover which collections have one and fall back
     * to `slug` (which is always present) otherwise.
     */
    

Test caveat is accurate — the new regression test admits it can't reproduce the D1 compound-SELECT cap under better-sqlite3. It exercises the merge/sort/slice contract, which is fine as a behavioral guard against a future revert; calling that out explicitly in the test comment is the right move.

Test timing is tight but should be fine — the 5ms setTimeout between creates relies on updated_at ms-resolution from new Date().toISOString(). Matches the pattern at line 76–77 in the same file.

Other things checked

  • validateIdentifier(col.slug) still gates the ec_${col.slug} table name interpolation. ✓
  • sql.lit(col.label) is preserved as-is from main, so any pre-existing concern there is not a regression introduced by this PR. ✓
  • toSorted is supported (ES2023 target, used elsewhere in the codebase). ✓
  • No schema changes, no migrations, no API shape changes. ✓
  • Changeset present (emdash: patch). ✓

LGTM modulo the stale comments.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 3, 2026

Review posted successfully. Summary of findings:

  • Correctness: Fix is sound. UNION ALL → N parallel per-collection queries with JS merge/sort/slice. Semantics preserved.
  • Security: validateIdentifier(col.slug) still gates the table name interpolation; sql.lit(col.label) is unchanged from main.
  • Performance: Round-trip count is now O(N), but the function above already does an O(N) parallel fan-out for getStats, so the shape is consistent.
  • Tests: New regression test honestly admits it doesn't reproduce the D1 compound-SELECT cap under better-sqlite3 — it's a behavioral guard for the merge contract.
  • Nits flagged: Two stale doc comments at lines 105 and 117–126 still reference UNION ALL; suggested replacements provided.

github run

@masonjames masonjames mentioned this pull request May 4, 2026
18 tasks
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.

Dashboard 500 on D1: fetchRecentItems UNION ALL exceeds SQLITE_LIMIT_COMPOUND_SELECT

2 participants