diff --git a/.changeset/quiet-folders-search.md b/.changeset/quiet-folders-search.md new file mode 100644 index 0000000000..fe88cd217e --- /dev/null +++ b/.changeset/quiet-folders-search.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes media folder searches failing on Cloudflare D1 when long, Unicode, or punctuation-heavy search terms exceed its pattern limit. Searches match the complete term without truncation. diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts index 3b72439c05..f20206e111 100644 --- a/packages/core/src/database/repositories/media-folders.ts +++ b/packages/core/src/database/repositories/media-folders.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { isPostgres } from "../dialect-helpers.js"; import type { Database } from "../types.js"; import { decodeCursor, EmDashValidationError, encodeCursor, type FindManyResult } from "./types.js"; @@ -15,10 +16,6 @@ export interface FindManyMediaFoldersOptions { q?: string; } -function escapeLike(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); -} - function normalizeFolderSearch(value: string): string { return value.trim().normalize("NFKC").toLowerCase(); } @@ -48,8 +45,11 @@ export class MediaFolderRepository { const term = normalizeFolderSearch(options.q ?? ""); if (term) { - const pattern = `%${escapeLike(term)}%`; - query = query.where("name_key", "like", sql`${pattern} escape '\\'`); + // D1 limits LIKE patterns to 50 bytes, including escapes and wildcards. + const position = isPostgres(this.db) + ? sql`strpos(name_key, ${term})` + : sql`instr(name_key, ${term})`; + query = query.where(position, ">", 0); } if (options.cursor !== undefined) { diff --git a/packages/core/tests/workerd/media-folder-search-d1.test.ts b/packages/core/tests/workerd/media-folder-search-d1.test.ts new file mode 100644 index 0000000000..cd1a14f73e --- /dev/null +++ b/packages/core/tests/workerd/media-folder-search-d1.test.ts @@ -0,0 +1,34 @@ +import { env } from "cloudflare:test"; +import { Kysely } from "kysely"; +import { expect, it } from "vitest"; + +import { RawBindingD1Dialect } from "../../../cloudflare/src/db/d1-dialect.js"; +import { runMigrations } from "../../src/database/migrations/runner.js"; +import { MediaFolderRepository } from "../../src/database/repositories/media-folders.js"; +import type { Database } from "../../src/database/types.js"; +import { resetD1Schema } from "./d1-schema.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv { + DB: D1Database; + } +} + +it("searches the complete 49-character folder substring on D1", async () => { + const db = new Kysely({ dialect: new RawBindingD1Dialect({ database: env.DB }) }); + try { + await resetD1Schema(db); + await runMigrations(db); + const folders = new MediaFolderRepository(db); + const term = `${"a".repeat(48)}b`; + const match = await folders.create(`prefix ${term} suffix`); + await folders.create(`prefix ${term.slice(0, -1)}c suffix`); + + expect(await folders.findMany({ q: term })).toEqual({ + items: [match], + nextCursor: undefined, + }); + } finally { + await db.destroy(); + } +});