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/old-badgers-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes `D1_ERROR: too many SQL variables` on the admin content list when a collection has ~100+ items. SEO and byline batch-hydration queries now chunk their `IN` clauses to stay under D1's 100-bound-parameter statement limit.
105 changes: 60 additions & 45 deletions packages/core/src/database/repositories/byline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ function rowToByline(row: BylineRow): BylineSummary {
export class BylineRepository {
constructor(private db: Kysely<Database>) {}

/** Single batched SELECT for getContentBylinesMany. Caller must keep
* `contentIds` under D1's 100-parameter statement limit. */
private async fetchBylineCreditsChunk(collectionSlug: string, contentIds: string[]) {
return this.db
.selectFrom("_emdash_content_bylines as cb")
.innerJoin("_emdash_bylines as b", "b.id", "cb.byline_id")
.select([
"cb.content_id as content_id",
"cb.sort_order as sort_order",
"cb.role_label as role_label",
"b.id as id",
"b.slug as slug",
"b.display_name as display_name",
"b.bio as bio",
"b.avatar_media_id as avatar_media_id",
"b.website_url as website_url",
"b.user_id as user_id",
"b.is_guest as is_guest",
"b.created_at as created_at",
"b.updated_at as updated_at",
])
.where("cb.collection_slug", "=", collectionSlug)
.where("cb.content_id", "in", contentIds)
.orderBy("cb.sort_order", "asc")
.execute();
}

async findById(id: string): Promise<BylineSummary | null> {
const row = await this.db
.selectFrom("_emdash_bylines")
Expand Down Expand Up @@ -249,8 +276,11 @@ export class BylineRepository {
}

/**
* Batch-fetch byline credits for multiple content items in a single query.
* Batch-fetch byline credits for multiple content items.
* Returns a Map keyed by contentId.
*
* Chunked to stay under D1's 100-parameter statement limit (one slot is
* consumed by `collectionSlug`, leaving room for 99 ids; chunk 90 for headroom).
*/
async getContentBylinesMany(
collectionSlug: string,
Expand All @@ -259,41 +289,22 @@ export class BylineRepository {
const result = new Map<string, ContentBylineCredit[]>();
if (contentIds.length === 0) return result;

const rows = await this.db
.selectFrom("_emdash_content_bylines as cb")
.innerJoin("_emdash_bylines as b", "b.id", "cb.byline_id")
.select([
"cb.content_id as content_id",
"cb.sort_order as sort_order",
"cb.role_label as role_label",
"b.id as id",
"b.slug as slug",
"b.display_name as display_name",
"b.bio as bio",
"b.avatar_media_id as avatar_media_id",
"b.website_url as website_url",
"b.user_id as user_id",
"b.is_guest as is_guest",
"b.created_at as created_at",
"b.updated_at as updated_at",
])
.where("cb.collection_slug", "=", collectionSlug)
.where("cb.content_id", "in", contentIds)
.orderBy("cb.sort_order", "asc")
.execute();

for (const row of rows) {
const contentId = row.content_id;
const credit: ContentBylineCredit = {
byline: rowToByline(row),
sortOrder: row.sort_order,
roleLabel: row.role_label,
};
const existing = result.get(contentId);
if (existing) {
existing.push(credit);
} else {
result.set(contentId, [credit]);
const CHUNK_SIZE = 90;
for (let i = 0; i < contentIds.length; i += CHUNK_SIZE) {
const chunk = contentIds.slice(i, i + CHUNK_SIZE);
const rows = await this.fetchBylineCreditsChunk(collectionSlug, chunk);
for (const row of rows) {
const credit: ContentBylineCredit = {
byline: rowToByline(row),
sortOrder: row.sort_order,
roleLabel: row.role_label,
};
const existing = result.get(row.content_id);
if (existing) {
existing.push(credit);
} else {
result.set(row.content_id, [credit]);
}
}
}

Expand All @@ -308,15 +319,19 @@ export class BylineRepository {
const result = new Map<string, BylineSummary>();
if (userIds.length === 0) return result;

const rows = await this.db
.selectFrom("_emdash_bylines")
.selectAll()
.where("user_id", "in", userIds)
.execute();

for (const row of rows) {
if (row.user_id) {
result.set(row.user_id, rowToByline(row));
// Chunked to stay under D1's 100-parameter statement limit.
const CHUNK_SIZE = 90;
for (let i = 0; i < userIds.length; i += CHUNK_SIZE) {
const chunk = userIds.slice(i, i + CHUNK_SIZE);
const rows = await this.db
.selectFrom("_emdash_bylines")
.selectAll()
.where("user_id", "in", chunk)
.execute();
for (const row of rows) {
if (row.user_id) {
result.set(row.user_id, rowToByline(row));
}
}
}
return result;
Expand Down
45 changes: 28 additions & 17 deletions packages/core/src/database/repositories/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ function hasAnyField(input: ContentSeoInput): boolean {
export class SeoRepository {
constructor(private db: Kysely<Database>) {}

/** Single batched SELECT for an IN-clause chunk. Caller is responsible for
* keeping the chunk under D1's 100-parameter statement limit. */
private async fetchSeoChunk(collection: string, contentIds: string[]) {
return this.db
.selectFrom("_emdash_seo")
.selectAll()
.where("collection", "=", collection)
.where("content_id", "in", contentIds)
.execute();
}

/**
* Get SEO data for a content item. Returns null defaults if no row exists.
*/
Expand All @@ -61,36 +72,36 @@ export class SeoRepository {
}

/**
* Get SEO data for multiple content items in a single query.
* Get SEO data for multiple content items.
* Returns a Map keyed by content_id. Items without SEO rows get defaults.
*
* The IN clause is chunked because D1 (and SQLite by default) caps a single
* statement at 100 bound parameters. With one slot consumed by `collection`,
* we can fit at most 99 ids per batch — chunk size 90 leaves headroom.
*/
async getMany(collection: string, contentIds: string[]): Promise<Map<string, ContentSeo>> {
const result = new Map<string, ContentSeo>();

if (contentIds.length === 0) return result;

// Batch query — single SELECT with IN clause
const rows = await this.db
.selectFrom("_emdash_seo")
.selectAll()
.where("collection", "=", collection)
.where("content_id", "in", contentIds)
.execute();

// Index fetched rows by content_id
const rowMap = new Map(rows.map((r) => [r.content_id, r]));

for (const id of contentIds) {
const row = rowMap.get(id);
if (row) {
result.set(id, {
const CHUNK_SIZE = 90;
for (let i = 0; i < contentIds.length; i += CHUNK_SIZE) {
const chunk = contentIds.slice(i, i + CHUNK_SIZE);
const rows = await this.fetchSeoChunk(collection, chunk);
for (const row of rows) {
result.set(row.content_id, {
title: row.seo_title ?? null,
description: row.seo_description ?? null,
image: row.seo_image ?? null,
canonical: row.seo_canonical ?? null,
noIndex: row.seo_no_index === 1,
});
} else {
}
}

// Fill in defaults for ids that had no SEO row.
for (const id of contentIds) {
if (!result.has(id)) {
result.set(id, { ...SEO_DEFAULTS });
}
}
Expand Down
Loading