diff --git a/.changeset/fix-content-list-search.md b/.changeset/fix-content-list-search.md new file mode 100644 index 000000000..e93764d53 --- /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}_loc_upd` / `idx_{table}_loc_crt`) 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..cd43ea62c 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,22 @@ 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]); + + // The query the current `items` reflect: server-side filtering lags behind + // typing by the debounce, so the empty-state message must use the debounced + // term; client-side filtering is immediate, so it uses the live query. + const activeSearch = serverSearch ? debouncedSearch.trim() : searchQuery; // 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 +179,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 (
@@ -188,7 +216,7 @@ export function ContentList({
{/* Search */} - {items.length > 0 && ( + {(serverSearch || items.length > 0) && (
- {t`No ${collectionLabel.toLowerCase()} yet.`}{" "} - - {t`Create your first one`} - + {activeSearch ? ( + t`No results for "${activeSearch}"` + ) : ( + <> + {t`No ${collectionLabel.toLowerCase()} yet.`}{" "} + + {t`Create your first one`} + + + )} ) : paginatedItems.length === 0 ? ( - {t`No results for "${searchQuery}"`} + {t`No results for "${activeSearch}"`} ) : ( @@ -315,10 +349,11 @@ export function ContentList({
{renderItemCount({ - searchQuery, + searchQuery: activeSearch, filteredCount: filteredItems.length, total, hasMore, + serverSearch, })}
@@ -485,7 +520,9 @@ function SortableTh({ field, sort, onSortChange, label }: SortableThProps) { /** * Render the row-count line above pagination. The rules are: - * - A search query always wins — say how many matches there are. + * - A search query always wins — say how many matches there are. In + * server-search mode the server reports the full match count via `total`; + * `filteredCount` is only the loaded page, so it would undercount. * - When the server reported a total, use it (no `+` suffix needed — * we know the count). * - Otherwise fall back to the pre-refactor behavior: loaded count, @@ -496,14 +533,17 @@ function renderItemCount({ filteredCount, total, hasMore, + serverSearch, }: { searchQuery: string; filteredCount: number; total: number | undefined; hasMore: boolean | undefined; + serverSearch: boolean; }): string { if (searchQuery) { - return plural(filteredCount, { + const matchCount = serverSearch && typeof total === "number" ? total : filteredCount; + return plural(matchCount, { one: `# item matching "${searchQuery}"`, other: `# items matching "${searchQuery}"`, }); 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..67e569d58 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -418,6 +418,80 @@ 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(); + }); + + // #1219: in server mode a zero-match query empties `items`. The search box + // must stay mounted so the user can clear the query. + it("keeps the search input mounted in server mode when there are no items", async () => { + const onSearchChange = vi.fn(); + const screen = await render( + , + ); + await expect + .element(screen.getByRole("searchbox", { name: "Search posts" })) + .toBeInTheDocument(); + }); + + // #1219: a zero-match server search must not show "Create your first one" + // (there is content, it just doesn't match), it must report the query. + it("shows a no-results message, not the empty state, for a zero-match server search", async () => { + const onSearchChange = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole("searchbox").fill("zzzzz"); + + await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument(); + expect(screen.getByText("Create your first one").query()).toBeNull(); + }); + + // #1219: the match count must come from the server `total`, not the loaded + // page length, otherwise it undercounts when matches span multiple pages. + it("counts server-search matches using total, not the loaded page", async () => { + const onSearchChange = vi.fn(); + const items = Array.from({ length: 20 }, (_, i) => + makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }), + ); + const screen = await render( + , + ); + + await screen.getByRole("searchbox").fill("post"); + + await expect.element(screen.getByText(/143 items matching "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..98c7f4073 --- /dev/null +++ b/packages/core/src/database/migrations/041_content_locale_list_index.ts @@ -0,0 +1,47 @@ +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`). + * + * Index names use a short `loc_upd`/`loc_crt` suffix rather than spelling out + * `deleted_locale_updated_id`: Postgres truncates identifiers to 63 bytes, and + * the longer form pushes the `updated`/`created` discriminator past byte 63 for + * slugs as short as 40 chars, making both names truncate to the same string. + * Keep these identical to the names in `schema/registry.ts`. + */ +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}_loc_upd`)} + 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}_loc_crt`)} + 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}_loc_upd`)}`.execute(db); + await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${tableName}_loc_crt`)}`.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..0fd1a4247 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -772,6 +772,20 @@ 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). + // Short `loc_upd`/`loc_crt` suffix keeps the updated/created discriminator + // inside Postgres's 63-byte identifier limit for long slugs; keep these + // names identical to migration 041. + await sql` + CREATE INDEX ${sql.ref(`idx_${tableName}_loc_upd`)} + ON ${sql.ref(tableName)} (deleted_at, locale, updated_at DESC, id DESC) + `.execute(conn); + + await sql` + CREATE INDEX ${sql.ref(`idx_${tableName}_loc_crt`)} + 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(); diff --git a/packages/core/tests/unit/database/migrations/041_content_locale_list_index.test.ts b/packages/core/tests/unit/database/migrations/041_content_locale_list_index.test.ts new file mode 100644 index 000000000..c9c3d60f2 --- /dev/null +++ b/packages/core/tests/unit/database/migrations/041_content_locale_list_index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +/** + * The locale-aware list indexes (migration 041 / schema/registry.ts) are named + * `idx_{table}_loc_upd` and `idx_{table}_loc_crt` where `table = ec_{slug}`. + * Postgres truncates identifiers to 63 bytes, so the names must keep the + * `upd`/`crt` discriminator inside the first 63 bytes for the longest slugs we + * accept; otherwise both names truncate to the same identifier and the second + * index either silently disappears (migration's `IF NOT EXISTS`) or hard-fails + * collection creation (registry's plain `CREATE INDEX`). + * + * These names must stay identical between the two sources. The earlier + * `deleted_locale_updated_id` / `deleted_locale_created_id` form collided from + * slugs as short as 40 chars. + */ + +function localeUpdatedIndexName(table: string): string { + return `idx_${table}_loc_upd`; +} + +function localeCreatedIndexName(table: string): string { + return `idx_${table}_loc_crt`; +} + +function truncateToPostgresIdentifier(name: string): string { + return Buffer.from(name, "utf8").subarray(0, 63).toString("utf8"); +} + +describe("041 locale list index names", () => { + it("stay <=63 bytes and distinct after Postgres truncation for a 47-char slug", () => { + const table = `ec_${"a".repeat(47)}`; + const updated = localeUpdatedIndexName(table); + const created = localeCreatedIndexName(table); + + expect(Buffer.byteLength(updated, "utf8")).toBeLessThanOrEqual(63); + expect(Buffer.byteLength(created, "utf8")).toBeLessThanOrEqual(63); + expect(truncateToPostgresIdentifier(updated)).not.toBe(truncateToPostgresIdentifier(created)); + }); + + it("stay distinct after truncation for every slug length up to 50 chars", () => { + for (let slugLength = 1; slugLength <= 50; slugLength++) { + const table = `ec_${"a".repeat(slugLength)}`; + const updated = truncateToPostgresIdentifier(localeUpdatedIndexName(table)); + const created = truncateToPostgresIdentifier(localeCreatedIndexName(table)); + expect(updated, `slug length ${slugLength}`).not.toBe(created); + } + }); +});