From 7545b03f76caffa6066832dd03dd85d0aa84e09b Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 29 May 2026 16:00:33 -0700 Subject: [PATCH] fix: server-side content list search + locale-aware list indexes The admin content list filtered only the rows already loaded on the current page, so entries far back in a large collection were unfindable until you navigated near them. Add a `q` query param to the content list endpoint that performs a case-insensitive substring search across the collection's title/name/slug columns (LIKE wildcards escaped), and wire the admin search box to drive it (debounced) instead of filtering in memory. Add locale-aware composite indexes so i18n-filtered lists stay index-served on large tables. Closes #1219. --- .changeset/fix-content-list-search.md | 6 ++ packages/admin/src/components/ContentList.tsx | 39 ++++++-- packages/admin/src/lib/api/content.ts | 3 + packages/admin/src/router.tsx | 8 +- .../tests/components/ContentList.test.tsx | 26 ++++++ packages/core/src/api/handlers/content.ts | 42 ++++++++- packages/core/src/api/schemas/content.ts | 2 + .../041_content_locale_list_index.ts | 45 +++++++++ .../core/src/database/migrations/runner.ts | 2 + .../core/src/database/repositories/content.ts | 46 +++++++++- .../core/src/database/repositories/types.ts | 8 ++ packages/core/src/emdash-runtime.ts | 1 + packages/core/src/schema/registry.ts | 11 +++ .../content/content-list-search.test.ts | 92 +++++++++++++++++++ .../integration/database/migrations.test.ts | 1 + 15 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-content-list-search.md create mode 100644 packages/core/src/database/migrations/041_content_locale_list_index.ts create mode 100644 packages/core/tests/integration/content/content-list-search.test.ts diff --git a/.changeset/fix-content-list-search.md b/.changeset/fix-content-list-search.md new file mode 100644 index 000000000..3b487a71d --- /dev/null +++ b/.changeset/fix-content-list-search.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Make content list search work on large collections (#1219). The admin content list previously filtered only the rows already loaded on the current page, so an entry far back in a big collection could not be found until you navigated near it. The list endpoint now accepts a `q` parameter and performs a case-insensitive substring search across the collection's title/name/slug columns server-side (LIKE wildcards in the query are escaped), and the admin search box drives that query (debounced) instead of filtering in memory. Also adds locale-aware composite indexes (`idx_{table}_deleted_locale_updated_id` / `_created_id`) so locale-filtered content lists stay index-served on large, i18n-enabled tables. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index a41cf3831..62c35dfd8 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router"; import * as React from "react"; import type { ContentItem, TrashedContentItem } from "../lib/api"; +import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; @@ -68,6 +69,13 @@ export interface ContentListProps { * growing as more API pages are fetched. */ total?: number; + /** + * When provided, search is performed server-side: the (debounced) query is + * reported here so the caller can refetch, and `items`/`total` are assumed + * to already reflect the filter. Without it, the list falls back to + * filtering the loaded page client-side (legacy behavior). + */ + onSearchChange?: (q: string) => void; } type ViewTab = "all" | "trash"; @@ -111,12 +119,23 @@ export function ContentList({ sort, onSortChange, total, + onSearchChange, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); const [searchQuery, setSearchQuery] = React.useState(""); const [page, setPage] = React.useState(0); + // Server-side search mode: the caller refetches based on the (debounced) + // query, so `items`/`total` already reflect the filter and we must not + // re-filter client-side (that would re-introduce the "only matches the + // loaded page" bug for non-title columns). + const serverSearch = !!onSearchChange; + const debouncedSearch = useDebouncedValue(searchQuery, 300); + React.useEffect(() => { + if (onSearchChange) onSearchChange(debouncedSearch.trim()); + }, [debouncedSearch, onSearchChange]); + // Reset page when search changes const handleSearchChange = (e: React.ChangeEvent) => { setSearchQuery(e.target.value); @@ -124,16 +143,17 @@ export function ContentList({ }; const filteredItems = React.useMemo(() => { - if (!searchQuery) return items; + if (serverSearch || !searchQuery) return items; const query = searchQuery.toLowerCase(); return items.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [items, searchQuery]); + }, [items, searchQuery, serverSearch]); // When the server reports a total, it's the source of truth for the - // denominator. Otherwise fall back to the size of the (possibly partial) - // client list, matching pre-refactor behavior. Client-side search always - // defers to `filteredItems` because `total` reflects the unfiltered set. - const effectiveTotal = typeof total === "number" && !searchQuery ? total : filteredItems.length; + // denominator. In server-search mode that total already reflects the query, + // so we use it even while searching; in client mode an active query falls + // back to the filtered client count. + const effectiveTotal = + typeof total === "number" && (serverSearch || !searchQuery) ? total : filteredItems.length; const totalPages = Math.max(1, Math.ceil(effectiveTotal / PAGE_SIZE)); // Clamp the current page in case filters collapse the count (user was on @@ -154,12 +174,15 @@ export function ContentList({ // The router wires this to TanStack Query's `fetchNextPage`, which is // idempotent while a fetch is in flight. React.useEffect(() => { - if (!hasMore || !onLoadMore || searchQuery) return; + // In client-search mode we skip auto-fetch while a query is active + // (filtering can collapse the list). In server-search mode the loaded + // items already are the matches, so paging forward should keep fetching. + if (!hasMore || !onLoadMore || (!serverSearch && searchQuery)) return; const loadedPages = Math.ceil(filteredItems.length / PAGE_SIZE); if (clampedPage >= loadedPages - 1) { onLoadMore(); } - }, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery]); + }, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery, serverSearch]); return (
diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 7d3fe3451..6b5e94a8e 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -143,6 +143,8 @@ export async function fetchContentList( orderBy?: string; /** Sort direction; defaults to "desc" on the server. */ order?: "asc" | "desc"; + /** Case-insensitive substring search across title/name/slug. */ + search?: string; }, ): Promise> { const params = new URLSearchParams(); @@ -152,6 +154,7 @@ export async function fetchContentList( if (options?.locale) params.set("locale", options.locale); if (options?.orderBy) params.set("orderBy", options.orderBy); if (options?.order) params.set("order", options.order); + if (options?.search) params.set("q", options.search); const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`; const response = await apiFetch(url); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 19176321e..f9a8dfc4a 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -307,9 +307,13 @@ function ContentListPage() { direction: "desc", }); + // Server-side search term (debounced inside ContentList). Part of the query + // key so a new term restarts the cursor chain from a filtered first page. + const [searchTerm, setSearchTerm] = React.useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ - queryKey: ["content", collection, { locale: activeLocale, sort }], + queryKey: ["content", collection, { locale: activeLocale, sort, search: searchTerm }], queryFn: ({ pageParam }) => fetchContentList(collection, { locale: activeLocale, @@ -317,6 +321,7 @@ function ContentListPage() { limit: 100, orderBy: sort.field, order: sort.direction, + search: searchTerm || undefined, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -441,6 +446,7 @@ function ContentListPage() { sort={sort} onSortChange={setSort} total={total} + onSearchChange={setSearchTerm} /> ); } diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index 1c9e4dfda..bf2fbf681 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -418,6 +418,32 @@ describe("ContentList", () => { await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument(); }); + + // #1219: when the caller opts into server-side search it reports the + // (debounced) query and must NOT also filter the loaded page client-side + // (the server already returned the matching rows). + it("reports the query upward and does not client-filter in server mode", async () => { + const onSearchChange = vi.fn(); + const items = [ + makeItem({ id: "1", data: { title: "Alpha post" } }), + makeItem({ id: "2", data: { title: "Beta post" } }), + ]; + const screen = await render( + , + ); + + await screen.getByRole("searchbox").fill("beta"); + + // Debounced callback fires with the typed term. + await vi.waitFor(() => { + expect(onSearchChange).toHaveBeenCalledWith("beta"); + }); + + // Server mode shows whatever `items` the caller supplied — it does not + // hide "Alpha post" by filtering locally. + await expect.element(screen.getByText("Alpha post")).toBeInTheDocument(); + await expect.element(screen.getByText("Beta post")).toBeInTheDocument(); + }); }); describe("pagination", () => { diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 87427cee8..2e0a3897e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -295,6 +295,34 @@ export interface TrashedContentItem { deletedAt: string; } +/** + * Resolve the columns a content-list search should match against. Always + * includes `slug` (a standard column) and adds the `title`/`name` display + * fields when the collection actually defines them, mirroring the admin's + * item-title resolution (title -> name -> slug). Returning only existing + * columns avoids "no such column" errors on collections without them. + */ +async function resolveSearchColumns(db: Kysely, collection: string): Promise { + const columns = ["slug"]; + const row = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collection) + .executeTakeFirst(); + if (!row) return columns; + + const fields = await db + .selectFrom("_emdash_fields") + .select("slug") + .where("collection_id", "=", row.id) + .execute(); + const fieldSlugs = new Set(fields.map((f) => f.slug)); + for (const candidate of ["title", "name"]) { + if (fieldSlugs.has(candidate)) columns.push(candidate); + } + return columns; +} + /** * Create content list handler */ @@ -308,14 +336,26 @@ export async function handleContentList( orderBy?: string; order?: "asc" | "desc"; locale?: string; + q?: string; }, ): Promise> { try { const repo = new ContentRepository(db); - const where: { status?: string; locale?: string } = {}; + const where: { + status?: string; + locale?: string; + q?: string; + searchColumns?: string[]; + } = {}; if (params.status) where.status = params.status; if (params.locale) where.locale = params.locale; + const q = params.q?.trim(); + if (q) { + where.q = q; + where.searchColumns = await resolveSearchColumns(db, collection); + } + const result = await repo.findMany(collection, { cursor: params.cursor, limit: params.limit || 50, diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index 7619d48ec..e7da1cee6 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -24,6 +24,8 @@ export const contentListQuery = cursorPaginationQuery orderBy: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), locale: localeCode.optional(), + /** Case-insensitive substring search across the collection's title/name/slug. */ + q: z.string().trim().min(1).max(200).optional(), }) .meta({ id: "ContentListQuery" }); diff --git a/packages/core/src/database/migrations/041_content_locale_list_index.ts b/packages/core/src/database/migrations/041_content_locale_list_index.ts new file mode 100644 index 000000000..e67bfcad5 --- /dev/null +++ b/packages/core/src/database/migrations/041_content_locale_list_index.ts @@ -0,0 +1,45 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +import { listTablesLike } from "../dialect-helpers.js"; + +/** + * Migration: locale-aware composite indexes for content list queries. + * + * Addresses GitHub issue #1219. When i18n is enabled the admin content list + * filters by `locale` and orders by `updated_at`/`created_at`. The existing + * composite indexes (033/034) cover `(deleted_at, updated_at DESC, id DESC)` + * etc. but omit `locale`, so a locale-filtered ordered list can't be served + * by a single index on large tables. These indexes restore index-only paging + * for the locale-scoped case. + * + * Forward-only and idempotent (`IF NOT EXISTS`). + */ +export async function up(db: Kysely): Promise { + const tableNames = await listTablesLike(db, "ec_%"); + + for (const tableName of tableNames) { + await sql` + CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_updated_id`)} + ON ${sql.ref(tableName)} (deleted_at, locale, updated_at DESC, id DESC) + `.execute(db); + + await sql` + CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_created_id`)} + ON ${sql.ref(tableName)} (deleted_at, locale, created_at DESC, id DESC) + `.execute(db); + } +} + +export async function down(db: Kysely): Promise { + const tableNames = await listTablesLike(db, "ec_%"); + + for (const tableName of tableNames) { + await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_updated_id`)}`.execute( + db, + ); + await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_created_id`)}`.execute( + db, + ); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 24e70d6c2..fd474ec2a 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -42,6 +42,7 @@ import * as m037 from "./037_credential_algorithm.js"; import * as m038 from "./038_registry_plugin_state.js"; import * as m039 from "./039_fix_fts5_triggers.js"; import * as m040 from "./040_byline_i18n.js"; +import * as m041 from "./041_content_locale_list_index.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -83,6 +84,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "038_registry_plugin_state": m038, "039_fix_fts5_triggers": m039, "040_byline_i18n": m040, + "041_content_locale_list_index": m041, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 3fbf6cd01..abb69af82 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -17,6 +17,9 @@ import { EmDashValidationError, encodeCursor, decodeCursor } from "./types.js"; // Regex pattern for ULID validation const ULID_PATTERN = /^[0-9A-Z]{26}$/; +// LIKE wildcards that must be escaped so user search input is matched literally. +const LIKE_WILDCARD_RE = /[\\%_]/g; + /** * System columns that exist in every ec_* table */ @@ -489,6 +492,8 @@ export class ContentRepository { query = query.where("locale" as any, "=", options.where.locale); } + query = this.applySearchFilter(query, options.where); + // Handle cursor pagination — decodeCursor throws InvalidCursorError // on malformed input; let it propagate so handlers surface a // structured INVALID_CURSOR rather than silently returning page 1. @@ -519,8 +524,8 @@ export class ContentRepository { .limit(limit + 1); // Run the page fetch and the unbounded count together — the UI needs - // both to render a stable denominator, and issuing them in parallel - // on SQLite is essentially free. + // both to render a stable denominator (kept on every page intentionally), + // and issuing them in parallel on SQLite is essentially free. const [rows, total] = await Promise.all([query.execute(), this.count(type, options.where)]); const hasMore = rows.length > limit; const items = rows.slice(0, limit); @@ -753,12 +758,45 @@ export class ContentRepository { return Number(result?.count || 0); } + /** + * Apply the optional case-insensitive `q` substring filter across the + * handler-resolved `searchColumns` (OR'd). User input is treated literally + * (LIKE wildcards escaped) and `lower()` is applied on both sides for + * SQLite/Postgres case-insensitive parity. + */ + private applySearchFilter unknown) => QB }>( + query: QB, + where?: { q?: string; searchColumns?: string[] }, + ): QB { + const term = where?.q?.trim(); + const columns = where?.searchColumns; + if (!term || !columns || columns.length === 0) return query; + + const escaped = term.replace(LIKE_WILDCARD_RE, (c) => `\\${c}`); + const pattern = `%${escaped}%`; + + return query.where((eb) => + eb.or( + columns.map((col) => { + validateIdentifier(col, "search column"); + return eb(sql`lower(${sql.ref(col)})`, "like", sql`lower(${pattern}) escape '\\'`); + }), + ), + ); + } + /** * Count content items */ async count( type: string, - where?: { status?: string; authorId?: string; locale?: string }, + where?: { + status?: string; + authorId?: string; + locale?: string; + q?: string; + searchColumns?: string[]; + }, ): Promise { const tableName = getTableName(type); @@ -779,6 +817,8 @@ export class ContentRepository { query = query.where("locale" as any, "=", where.locale); } + query = this.applySearchFilter(query, where); + const result = await query.executeTakeFirst(); return Number(result?.count || 0); } diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 4ecea29a5..29c18bc01 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -91,6 +91,14 @@ export interface FindManyOptions { status?: string; authorId?: string; locale?: string; + /** Case-insensitive substring to match against `searchColumns`. */ + q?: string; + /** + * Columns the `q` substring filter is applied to (OR'd together). + * Resolved by the handler from the collection's display fields so the + * repository stays generic. Each name is validated as a SQL identifier. + */ + searchColumns?: string[]; }; orderBy?: { field: string; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 388e474ba..4ca780733 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2121,6 +2121,7 @@ export class EmDashRuntime { orderBy?: string; order?: "asc" | "desc"; locale?: string; + q?: string; }, ) { return handleContentList(this.db, collection, params); diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 27fe1ca68..0fe8ac6aa 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -772,6 +772,17 @@ export class SchemaRegistry { CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_published_id`)} ON ${sql.ref(tableName)} (deleted_at, published_at DESC, id DESC) `.execute(conn); + + // Locale-aware composite indexes for i18n content lists (see migration 041) + await sql` + CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_locale_updated_id`)} + ON ${sql.ref(tableName)} (deleted_at, locale, updated_at DESC, id DESC) + `.execute(conn); + + await sql` + CREATE INDEX ${sql.ref(`idx_${tableName}_deleted_locale_created_id`)} + ON ${sql.ref(tableName)} (deleted_at, locale, created_at DESC, id DESC) + `.execute(conn); } /** diff --git a/packages/core/tests/integration/content/content-list-search.test.ts b/packages/core/tests/integration/content/content-list-search.test.ts new file mode 100644 index 000000000..8e8e20666 --- /dev/null +++ b/packages/core/tests/integration/content/content-list-search.test.ts @@ -0,0 +1,92 @@ +import { it, expect, beforeEach, afterEach } from "vitest"; + +import { handleContentCreate, handleContentList } from "../../../src/api/handlers/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +// Regression for #1219: content list search was client-side over the loaded +// page only, so an entry far back in a large collection could not be found. +// The list query now accepts a server-side `q` substring filter. +describeEachDialect("content list server-side search (#1219)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + // Seed 60 ordinary posts plus a "deep" needle far past the first page. + for (let i = 0; i < 60; i++) { + const created = await handleContentCreate(ctx.db, "posts", { + slug: `post-${String(i).padStart(3, "0")}`, + data: { title: `Ordinary Post ${i}` }, + }); + if (!created.success) throw new Error("seed failed"); + } + const needle = await handleContentCreate(ctx.db, "posts", { + slug: "the-needle-post", + data: { title: "zzz Needle Headline" }, + }); + if (!needle.success) throw new Error("needle seed failed"); + + // A title containing a literal % to prove wildcards are escaped. + const pct = await handleContentCreate(ctx.db, "posts", { + slug: "percent-post", + data: { title: "50% off sale" }, + }); + if (!pct.success) throw new Error("percent seed failed"); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + function titlesOf(result: { + success: boolean; + data?: { items: { data: Record }[] }; + }) { + if (!result.success || !result.data) throw new Error("list failed"); + return result.data.items.map((i) => i.data.title as string); + } + + it("finds an entry that lives far past the first page", async () => { + const result = await handleContentList(ctx.db, "posts", { q: "Needle", limit: 20 }); + expect(titlesOf(result)).toContain("zzz Needle Headline"); + }); + + it("matches case-insensitively", async () => { + const result = await handleContentList(ctx.db, "posts", { q: "needle", limit: 20 }); + expect(titlesOf(result)).toContain("zzz Needle Headline"); + + const upper = await handleContentList(ctx.db, "posts", { q: "NEEDLE", limit: 20 }); + expect(titlesOf(upper)).toContain("zzz Needle Headline"); + }); + + it("searches the slug as well as the title", async () => { + const result = await handleContentList(ctx.db, "posts", { q: "the-needle-post", limit: 20 }); + expect(titlesOf(result)).toContain("zzz Needle Headline"); + }); + + it("treats LIKE wildcards in the query literally", async () => { + // "50%" must match only the "50% off sale" title — not every row (which + // is what an unescaped trailing % wildcard would do). + const result = await handleContentList(ctx.db, "posts", { q: "50%", limit: 100 }); + const titles = titlesOf(result); + expect(titles).toContain("50% off sale"); + expect(titles).not.toContain("Ordinary Post 0"); + }); + + it("returns the full unfiltered list when no query is given", async () => { + const result = await handleContentList(ctx.db, "posts", { limit: 20 }); + if (!result.success) throw new Error("list failed"); + // 62 total rows; first page capped at the limit. + expect(result.data.items).toHaveLength(20); + expect(result.data.total).toBe(62); + }); +}); diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 546334103..f86772fbc 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -120,6 +120,7 @@ describe("Database Migrations (Integration)", () => { "038_registry_plugin_state", "039_fix_fts5_triggers", "040_byline_i18n", + "041_content_locale_list_index", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();