Skip to content
Open
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/fix-search-titleless-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fix search and suggestions failing with `D1_ERROR: no such column: title` when a searchable collection has no `title` field. The query now detects whether the collection defines a title field and only selects it when present.
30 changes: 27 additions & 3 deletions packages/core/src/search/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ async function searchSingleCollection(

// Get searchable fields for snippet generation
const searchableFields = await ftsManager.getSearchableFields(collection);
const hasTitleField = await collectionHasField(db, collection, "title");
const titleSelection = hasTitleField ? sql.ref("c.title") : sql<string | null>`NULL`;

// Build weight string for bm25 if weights provided
// Format: bm25(table, weight1, weight2, ...)
Expand Down Expand Up @@ -229,7 +231,7 @@ async function searchSingleCollection(
c.id,
c.slug,
c.locale,
c.title,
${titleSelection} as title,
snippet("${sql.raw(ftsTable)}", 2, '<mark>', '</mark>', '...', 32) as snippet,
${sql.raw(bm25Expr)} as score
FROM "${sql.raw(ftsTable)}" f
Expand Down Expand Up @@ -347,6 +349,9 @@ export async function getSuggestions(

const ftsTable = ftsManager.getFtsTableName(collection);
const contentTable = ftsManager.getContentTableName(collection);
const hasTitleField = await collectionHasField(db, collection, "title");
const titleSelection = hasTitleField ? sql.ref("c.title") : sql<string>`COALESCE(c.slug, c.id)`;
const titleRequired = hasTitleField ? sql`AND c.title IS NOT NULL` : sql``;

// Use prefix search for autocomplete. `escapeQuery` already appends `*`
// to each term for prefix matching, so we must not append another one.
Expand All @@ -363,13 +368,13 @@ export async function getSuggestions(
}>`
SELECT
c.id,
c.title
${titleSelection} as title
FROM "${sql.raw(ftsTable)}" f
JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
AND c.status = 'published'
AND c.deleted_at IS NULL
AND c.title IS NOT NULL
${titleRequired}
${locale ? sql`AND c.locale = ${locale}` : sql``}
ORDER BY bm25("${sql.raw(ftsTable)}")
LIMIT ${limit}
Expand Down Expand Up @@ -397,6 +402,25 @@ export async function getSuggestions(
return suggestions.slice(0, limit);
}

async function collectionHasField(
db: Kysely<Database>,
collection: string,
field: string,
): Promise<boolean> {
validateIdentifier(collection, "collection slug");
validateIdentifier(field, "field slug");

const row = await db
.selectFrom("_emdash_collections as c")
.innerJoin("_emdash_fields as f", "f.collection_id", "c.id")
.select("f.id")
.where("c.slug", "=", collection)
.where("f.slug", "=", field)
.executeTakeFirst();

return row !== undefined;
}

/**
* Get search statistics for all collections
*/
Expand Down
61 changes: 61 additions & 0 deletions packages/core/tests/integration/mcp/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ async function setupSearchablePostCollection(db: Kysely<Database>): Promise<void
await new FTSManager(db).enableSearch("post");
}

async function setupSearchableNoteCollection(db: Kysely<Database>): Promise<void> {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "note",
label: "Notes",
supports: ["drafts", "revisions", "search"],
});
await registry.createField("note", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
await new FTSManager(db).enableSearch("note");
}

describe("search", () => {
let db: Kysely<Database>;
let harness: McpHarness;
Expand Down Expand Up @@ -130,6 +146,51 @@ describe("search", () => {
expect(data.items.find((i) => i.id === id)).toBeTruthy();
});

it("searches all collections when one searchable collection has no title field", async () => {
await setupSearchablePostCollection(db);
await setupSearchableNoteCollection(db);
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });

const post = await harness.client.callTool({
name: "content_create",
arguments: {
collection: "post",
data: { title: "needle post", body: "searchable text" },
},
});
const note = await harness.client.callTool({
name: "content_create",
arguments: {
collection: "note",
data: { body: "needle note" },
},
});
await harness.client.callTool({
name: "content_publish",
arguments: {
collection: "post",
id: extractJson<{ item: { id: string } }>(post).item.id,
},
});
await harness.client.callTool({
name: "content_publish",
arguments: {
collection: "note",
id: extractJson<{ item: { id: string } }>(note).item.id,
},
});

const result = await harness.client.callTool({
name: "search",
arguments: { query: "needle" },
});
expect(result.isError, extractText(result)).toBeFalsy();
const data = extractJson<{ items: Array<{ collection?: string; type?: string }> }>(result);
const collections = data.items.map((item) => item.collection ?? item.type);
expect(collections).toContain("post");
expect(collections).toContain("note");
});

it("scopes search by collections argument", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
Expand Down
32 changes: 32 additions & 0 deletions packages/core/tests/integration/search/suggest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,36 @@ describe("getSuggestions (Integration)", () => {

expect(suggestions).toEqual([]);
});

it("returns suggestions across all collections when one searchable collection has no title field", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "note",
label: "Notes",
supports: ["search"],
});
await registry.createField("note", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
await new FTSManager(db).enableSearch("note");
await repo.create(
createPostFixture({
type: "note",
slug: "design-note",
status: "published",
data: { body: "Design note" },
}),
);

const suggestions = await getSuggestions(db, "note");

expect(suggestions).toContainEqual({
collection: "note",
id: expect.any(String),
title: "design-note",
});
});
});
4 changes: 2 additions & 2 deletions scripts/query-counts.snapshot.d1.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"GET /posts/building-for-the-long-term (warm)": 16,
"GET /rss.xml (cold)": 13,
"GET /rss.xml (warm)": 4,
"GET /search (cold)": 14,
"GET /search (warm)": 5,
"GET /search (cold)": 15,
"GET /search (warm)": 6,
"GET /tag/webdev (cold)": 18,
"GET /tag/webdev (warm)": 8
}
4 changes: 2 additions & 2 deletions scripts/query-counts.snapshot.sqlite.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"GET /posts/building-for-the-long-term (warm)": 17,
"GET /rss.xml (cold)": 4,
"GET /rss.xml (warm)": 4,
"GET /search (cold)": 6,
"GET /search (warm)": 6,
"GET /search (cold)": 7,
"GET /search (warm)": 7,
"GET /tag/webdev (cold)": 9,
"GET /tag/webdev (warm)": 9
}
Loading