Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stale-actors-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes dashboard recent items on D1 by avoiding compound SELECT queries that can exceed D1 limits on sites with many collections.
65 changes: 27 additions & 38 deletions packages/core/src/api/handlers/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ interface RecentItemRow {
/**
* Fetch the 10 most recently updated items across all collections.
*
* Uses UNION ALL over each ec_* table. The query is safe because
* collection slugs come from the system table and are validated.
* Queries each collection separately instead of building a single UNION ALL.
* D1 has a low compound SELECT limit, and sites with many collections can
* exceed it before SQLite does.
*
* `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
Expand All @@ -140,25 +141,21 @@ async function fetchRecentItems(

const collectionsWithTitle = new Set(titleFields.map((r) => r.collection_slug));

// Build a UNION ALL query across all content tables.
// Each branch is wrapped in SELECT * FROM (...) so the inner
// ORDER BY + LIMIT is valid SQLite (bare ORDER BY inside UNION
// branches is a syntax error in SQLite).
const subQueries = collections.map((col) => {
validateIdentifier(col.slug);
const table = `ec_${col.slug}`;
const hasTitle = collectionsWithTitle.has(col.slug);

// Use title column if it exists, otherwise fall back to slug → id.
// All output uses snake_case to avoid SQLite quoting issues on D1.
const titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;

return sql<RecentItemRow>`
SELECT * FROM (
const rowsByCollection = await Promise.all(
collections.map(async (col) => {
validateIdentifier(col.slug);
const table = `ec_${col.slug}`;
const hasTitle = collectionsWithTitle.has(col.slug);

// Use title column if it exists, otherwise fall back to slug -> id.
// All output uses snake_case to avoid SQLite quoting issues on D1.
const titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;

const result = await sql<RecentItemRow>`
SELECT
id,
${sql.lit(col.slug)} AS collection,
${sql.lit(col.label)} AS collection_label,
${col.slug} AS collection,
${col.label} AS collection_label,
${titleExpr} AS title,
slug,
status,
Expand All @@ -168,27 +165,19 @@ async function fetchRecentItems(
WHERE deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT 10
)
`;
});

// Combine with UNION ALL
// eslint-disable-next-line typescript-eslint(no-unnecessary-type-assertion) -- noUncheckedIndexedAccess
let combined = subQueries[0]!;
for (let i = 1; i < subQueries.length; i++) {
// eslint-disable-next-line typescript-eslint(no-unnecessary-type-assertion) -- noUncheckedIndexedAccess
combined = sql<RecentItemRow>`${combined} UNION ALL ${subQueries[i]!}`;
}
`.execute(db);

return result.rows;
}),
);

// Final sort + limit across all branches
const result = await sql<RecentItemRow>`
SELECT * FROM (${combined})
ORDER BY updated_at DESC
LIMIT 10
`.execute(db);
const rows = rowsByCollection
.flat()
.toSorted((a, b) => b.updated_at.localeCompare(a.updated_at))
.slice(0, 10);

// Map snake_case DB rows camelCase API shape
return result.rows.map((row) => ({
// Map snake_case DB rows -> camelCase API shape
return rows.map((row) => ({
id: row.id,
collection: row.collection,
collectionLabel: row.collection_label,
Expand Down
50 changes: 49 additions & 1 deletion packages/core/tests/unit/api/dashboard-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Kysely } from "kysely";
import type { Kysely, KyselyPlugin } from "kysely";
import { describe, it, expect, afterEach } from "vitest";

import { handleDashboardStats } from "../../../src/api/handlers/dashboard.js";
Expand All @@ -12,6 +12,19 @@ import {
teardownTestDatabase,
} from "../../utils/test-db.js";

const rejectCompoundSelectPlugin = {
transformQuery(args) {
if (JSON.stringify(args.node).includes(" UNION ALL ")) {
Comment on lines +15 to +17
throw new Error("D1_ERROR: too many terms in compound SELECT: SQLITE_ERROR");
}

return args.node;
},
async transformResult(args) {
return args.result;
},
} satisfies KyselyPlugin;

describe("Dashboard Handlers", () => {
describe("handleDashboardStats", () => {
let db: Kysely<Database>;
Expand Down Expand Up @@ -95,6 +108,41 @@ describe("Dashboard Handlers", () => {
expect(recentItems[1]!.slug).toBe("post-1");
});

it("does not use a compound recent-items query that exceeds D1 limits", async () => {
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
const contentRepo = new ContentRepository(db);

for (let i = 0; i < 12; i++) {
const slug = `section_${String(i).padStart(2, "0")}`;
await registry.createCollection({
slug,
label: `Section ${i}`,
labelSingular: "Section",
});
await registry.createField(slug, {
slug: "title",
label: "Title",
type: "string",
});
await contentRepo.create({
type: slug,
slug: `item-${i}`,
data: { title: `Item ${i}` },
status: "draft",
});
}

const d1LikeDb = db.withPlugin(rejectCompoundSelectPlugin);
const result = await handleDashboardStats(d1LikeDb);
Comment on lines +136 to +137

expect(result.success).toBe(true);
expect(result.data!.recentItems).toHaveLength(10);
expect(result.data!.recentItems.every((item) => item.collection.startsWith("section_"))).toBe(
true,
);
});

it("recent items use title field when available", async () => {
db = await setupTestDatabaseWithCollections();
const contentRepo = new ContentRepository(db);
Expand Down
Loading