From 1cd684376f8c3ee576c10ea1fa4ed6b8cde17cfa Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 15:46:00 -0700 Subject: [PATCH 1/2] fix: support long media folder searches on D1 --- .changeset/quiet-folders-search.md | 5 ++ .../database/repositories/media-folders.ts | 12 ++--- .../database/media-folders.test.ts | 3 ++ .../core/tests/utils/media-folder-search.ts | 49 +++++++++++++++++++ .../workerd/media-folder-search-d1.test.ts | 38 ++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 .changeset/quiet-folders-search.md create mode 100644 packages/core/tests/utils/media-folder-search.ts create mode 100644 packages/core/tests/workerd/media-folder-search-d1.test.ts 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/integration/database/media-folders.test.ts b/packages/core/tests/integration/database/media-folders.test.ts index c0aa0319b5..d7f070d7f1 100644 --- a/packages/core/tests/integration/database/media-folders.test.ts +++ b/packages/core/tests/integration/database/media-folders.test.ts @@ -5,6 +5,7 @@ import * as migration072 from "../../../src/database/migrations/072_media_folder import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; import { MediaRepository } from "../../../src/database/repositories/media.js"; import { EmDashValidationError } from "../../../src/database/repositories/types.js"; +import { testMediaFolderSearch } from "../../utils/media-folder-search.js"; import { describeEachDialect, setupForDialect, @@ -27,6 +28,8 @@ describeEachDialect("media folder storage and repositories", (dialect) => { await teardownForDialect(ctx); }); + testMediaFolderSearch(() => folders); + it("upgrades existing media into the Main library and supports rollback", async () => { await migration072.down(ctx.db); await ctx.db diff --git a/packages/core/tests/utils/media-folder-search.ts b/packages/core/tests/utils/media-folder-search.ts new file mode 100644 index 0000000000..c2db294f27 --- /dev/null +++ b/packages/core/tests/utils/media-folder-search.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; + +import type { MediaFolderRepository } from "../../src/database/repositories/media-folders.js"; + +export function testMediaFolderSearch(getFolders: () => MediaFolderRepository): void { + it.each([ + ["long ASCII", `${"a".repeat(48)}b`], + ["multibyte Unicode", "資料".repeat(9)], + ["literal wildcard characters", "%_\\".repeat(9)], + ["Unicode normalization expansion", "ffi".repeat(17)], + ])("finds the complete %s substring", async (_label, term) => { + const folders = getFolders(); + const match = await folders.create(`prefix ${term} suffix`); + await folders.create(`prefix ${term.slice(0, -1)}x suffix`); + + expect(await folders.findMany({ q: ` ${term.toUpperCase()} ` })).toEqual({ + items: [match], + nextCursor: undefined, + }); + }); + + it("searches a maximum-length folder name", async () => { + const folders = getFolders(); + const name = "a".repeat(200); + const match = await folders.create(name); + await folders.create(`${name.slice(0, -1)}b`); + + expect(await folders.findMany({ q: name })).toEqual({ + items: [match], + nextCursor: undefined, + }); + }); + + it("paginates matching folders without including a truncated-term match", async () => { + const folders = getFolders(); + const term = `${"a".repeat(48)}b`; + const first = await folders.create(`A ${term}`); + await folders.create(`B ${term.slice(0, -1)}c`); + const second = await folders.create(`C ${term}`); + + const page = await folders.findMany({ q: term, limit: 1 }); + expect(page.items).toEqual([first]); + expect(page.nextCursor).toBeDefined(); + expect(await folders.findMany({ q: term, limit: 1, cursor: page.nextCursor })).toEqual({ + items: [second], + nextCursor: 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..f22517c8d1 --- /dev/null +++ b/packages/core/tests/workerd/media-folder-search-d1.test.ts @@ -0,0 +1,38 @@ +import { env } from "cloudflare:test"; +import { Kysely } from "kysely"; +import { afterAll, beforeAll, beforeEach, describe } 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 { testMediaFolderSearch } from "../utils/media-folder-search.js"; +import { resetD1Schema } from "./d1-schema.js"; + +declare module "cloudflare:test" { + interface ProvidedEnv { + DB: D1Database; + } +} + +describe("media folder search on D1", () => { + let db: Kysely; + let folders: MediaFolderRepository; + + beforeAll(async () => { + db = new Kysely({ dialect: new RawBindingD1Dialect({ database: env.DB }) }); + await resetD1Schema(db); + await runMigrations(db); + folders = new MediaFolderRepository(db); + }); + + beforeEach(async () => { + await db.deleteFrom("media_folders").execute(); + }); + + afterAll(async () => { + await db.destroy(); + }); + + testMediaFolderSearch(() => folders); +}); From 436a1b8b56d4cfdda731108329c85269d9536307 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Fri, 16 Oct 2026 15:49:51 -0700 Subject: [PATCH 2/2] test: keep one focused D1 folder search regression --- .../database/media-folders.test.ts | 3 -- .../core/tests/utils/media-folder-search.ts | 49 ------------------- .../workerd/media-folder-search-d1.test.ts | 32 ++++++------ 3 files changed, 14 insertions(+), 70 deletions(-) delete mode 100644 packages/core/tests/utils/media-folder-search.ts diff --git a/packages/core/tests/integration/database/media-folders.test.ts b/packages/core/tests/integration/database/media-folders.test.ts index d7f070d7f1..c0aa0319b5 100644 --- a/packages/core/tests/integration/database/media-folders.test.ts +++ b/packages/core/tests/integration/database/media-folders.test.ts @@ -5,7 +5,6 @@ import * as migration072 from "../../../src/database/migrations/072_media_folder import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; import { MediaRepository } from "../../../src/database/repositories/media.js"; import { EmDashValidationError } from "../../../src/database/repositories/types.js"; -import { testMediaFolderSearch } from "../../utils/media-folder-search.js"; import { describeEachDialect, setupForDialect, @@ -28,8 +27,6 @@ describeEachDialect("media folder storage and repositories", (dialect) => { await teardownForDialect(ctx); }); - testMediaFolderSearch(() => folders); - it("upgrades existing media into the Main library and supports rollback", async () => { await migration072.down(ctx.db); await ctx.db diff --git a/packages/core/tests/utils/media-folder-search.ts b/packages/core/tests/utils/media-folder-search.ts deleted file mode 100644 index c2db294f27..0000000000 --- a/packages/core/tests/utils/media-folder-search.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { expect, it } from "vitest"; - -import type { MediaFolderRepository } from "../../src/database/repositories/media-folders.js"; - -export function testMediaFolderSearch(getFolders: () => MediaFolderRepository): void { - it.each([ - ["long ASCII", `${"a".repeat(48)}b`], - ["multibyte Unicode", "資料".repeat(9)], - ["literal wildcard characters", "%_\\".repeat(9)], - ["Unicode normalization expansion", "ffi".repeat(17)], - ])("finds the complete %s substring", async (_label, term) => { - const folders = getFolders(); - const match = await folders.create(`prefix ${term} suffix`); - await folders.create(`prefix ${term.slice(0, -1)}x suffix`); - - expect(await folders.findMany({ q: ` ${term.toUpperCase()} ` })).toEqual({ - items: [match], - nextCursor: undefined, - }); - }); - - it("searches a maximum-length folder name", async () => { - const folders = getFolders(); - const name = "a".repeat(200); - const match = await folders.create(name); - await folders.create(`${name.slice(0, -1)}b`); - - expect(await folders.findMany({ q: name })).toEqual({ - items: [match], - nextCursor: undefined, - }); - }); - - it("paginates matching folders without including a truncated-term match", async () => { - const folders = getFolders(); - const term = `${"a".repeat(48)}b`; - const first = await folders.create(`A ${term}`); - await folders.create(`B ${term.slice(0, -1)}c`); - const second = await folders.create(`C ${term}`); - - const page = await folders.findMany({ q: term, limit: 1 }); - expect(page.items).toEqual([first]); - expect(page.nextCursor).toBeDefined(); - expect(await folders.findMany({ q: term, limit: 1, cursor: page.nextCursor })).toEqual({ - items: [second], - nextCursor: 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 index f22517c8d1..cd1a14f73e 100644 --- a/packages/core/tests/workerd/media-folder-search-d1.test.ts +++ b/packages/core/tests/workerd/media-folder-search-d1.test.ts @@ -1,12 +1,11 @@ import { env } from "cloudflare:test"; import { Kysely } from "kysely"; -import { afterAll, beforeAll, beforeEach, describe } from "vitest"; +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 { testMediaFolderSearch } from "../utils/media-folder-search.js"; import { resetD1Schema } from "./d1-schema.js"; declare module "cloudflare:test" { @@ -15,24 +14,21 @@ declare module "cloudflare:test" { } } -describe("media folder search on D1", () => { - let db: Kysely; - let folders: MediaFolderRepository; - - beforeAll(async () => { - db = new Kysely({ dialect: new RawBindingD1Dialect({ database: env.DB }) }); +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); - folders = new MediaFolderRepository(db); - }); - - beforeEach(async () => { - await db.deleteFrom("media_folders").execute(); - }); + 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`); - afterAll(async () => { + expect(await folders.findMany({ q: term })).toEqual({ + items: [match], + nextCursor: undefined, + }); + } finally { await db.destroy(); - }); - - testMediaFolderSearch(() => folders); + } });