From 761439c81feaf7630ef97cc30f5683c95bb0670a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 31 May 2026 11:11:51 -0700 Subject: [PATCH 1/3] search: don't assume a title column exists in searchable collections The search query and suggestion SQL hardcoded c.title, so searching a collection whose schema has no title field failed with D1_ERROR: no such column: title and broke the whole multi-collection search. Detect whether the collection actually has a title field (via the fields registry, with identifier validation) and select it only when present: NULL in the main search projection and COALESCE(slug, id) for suggestions, dropping the title IS NOT NULL filter when there is no title field. Adds integration tests with a title-less searchable collection. Closes #1178 --- .changeset/fix-search-titleless-collection.md | 5 ++ packages/core/src/search/query.ts | 32 +++++++++- .../core/tests/integration/mcp/search.test.ts | 61 +++++++++++++++++++ .../tests/integration/search/suggest.test.ts | 32 ++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-search-titleless-collection.md diff --git a/.changeset/fix-search-titleless-collection.md b/.changeset/fix-search-titleless-collection.md new file mode 100644 index 000000000..3fcb1f4be --- /dev/null +++ b/.changeset/fix-search-titleless-collection.md @@ -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. diff --git a/packages/core/src/search/query.ts b/packages/core/src/search/query.ts index 4f4f6464c..2047d7f49 100644 --- a/packages/core/src/search/query.ts +++ b/packages/core/src/search/query.ts @@ -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`NULL`; // Build weight string for bm25 if weights provided // Format: bm25(table, weight1, weight2, ...) @@ -229,7 +231,7 @@ async function searchSingleCollection( c.id, c.slug, c.locale, - c.title, + ${titleSelection} as title, snippet("${sql.raw(ftsTable)}", 2, '', '', '...', 32) as snippet, ${sql.raw(bm25Expr)} as score FROM "${sql.raw(ftsTable)}" f @@ -347,6 +349,11 @@ 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`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. @@ -363,13 +370,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} @@ -397,6 +404,25 @@ export async function getSuggestions( return suggestions.slice(0, limit); } +async function collectionHasField( + db: Kysely, + collection: string, + field: string, +): Promise { + 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 */ diff --git a/packages/core/tests/integration/mcp/search.test.ts b/packages/core/tests/integration/mcp/search.test.ts index b6bbbd2d8..20141aa43 100644 --- a/packages/core/tests/integration/mcp/search.test.ts +++ b/packages/core/tests/integration/mcp/search.test.ts @@ -54,6 +54,22 @@ async function setupSearchablePostCollection(db: Kysely): Promise): Promise { + 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; let harness: McpHarness; @@ -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({ diff --git a/packages/core/tests/integration/search/suggest.test.ts b/packages/core/tests/integration/search/suggest.test.ts index df917f90b..59ff6ec43 100644 --- a/packages/core/tests/integration/search/suggest.test.ts +++ b/packages/core/tests/integration/search/suggest.test.ts @@ -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", + }); + }); }); From cfab5635dc31c7ce5350b5a698ea84d97ead11e6 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 31 May 2026 18:18:39 +0000 Subject: [PATCH 2/3] style: format --- packages/core/src/search/query.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/search/query.ts b/packages/core/src/search/query.ts index 2047d7f49..7568af344 100644 --- a/packages/core/src/search/query.ts +++ b/packages/core/src/search/query.ts @@ -350,9 +350,7 @@ 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`COALESCE(c.slug, c.id)`; + const titleSelection = hasTitleField ? sql.ref("c.title") : sql`COALESCE(c.slug, c.id)`; const titleRequired = hasTitleField ? sql`AND c.title IS NOT NULL` : sql``; // Use prefix search for autocomplete. `escapeQuery` already appends `*` From c04d512b85608005c9c50692feb8014b0c3cb3cf Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 31 May 2026 18:22:55 +0000 Subject: [PATCH 3/3] ci: update query-count snapshots --- scripts/query-counts.snapshot.d1.json | 4 ++-- scripts/query-counts.snapshot.sqlite.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json index faeb3abec..76f2c6979 100644 --- a/scripts/query-counts.snapshot.d1.json +++ b/scripts/query-counts.snapshot.d1.json @@ -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 } diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 5461279e4..7b64d4bb0 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -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 }