From a8f872b7ec2dcbc1d892624944534364af552474 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:37:03 +0200 Subject: [PATCH 1/9] feat(admin): show configured fields in content lists --- .changeset/collection-list-columns.md | 6 + packages/admin/src/components/ContentList.tsx | 123 +++++++++++++++++- packages/admin/src/components/index.ts | 2 +- packages/admin/src/lib/api/client.ts | 1 + packages/admin/src/lib/api/schema.ts | 7 + packages/admin/src/router.tsx | 14 ++ .../tests/components/ContentList.test.tsx | 109 ++++++++++++++++ packages/core/src/api/schemas/schema.ts | 9 ++ packages/core/src/astro/types.ts | 2 + packages/core/src/cli/commands/export-seed.ts | 1 + .../migrations/056_collection_admin_config.ts | 16 +++ .../core/src/database/migrations/runner.ts | 2 + packages/core/src/database/types.ts | 1 + packages/core/src/emdash-runtime.ts | 33 +++++ packages/core/src/schema/registry.ts | 26 ++++ packages/core/src/schema/types.ts | 9 ++ packages/core/src/seed/apply.ts | 2 + packages/core/src/seed/types.ts | 3 +- packages/core/src/seed/validate.ts | 29 +++++ .../core/tests/database/migrations.test.ts | 1 + .../integration/database/migrations.test.ts | 1 + .../core/tests/unit/cli/seed-commands.test.ts | 34 +++++ .../tests/unit/runtime/manifest-build.test.ts | 65 ++++++++- .../core/tests/unit/schema/registry.test.ts | 13 ++ .../core/tests/unit/seed/validate.test.ts | 19 +++ 25 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 .changeset/collection-list-columns.md create mode 100644 packages/core/src/database/migrations/056_collection_admin_config.ts diff --git a/.changeset/collection-list-columns.md b/.changeset/collection-list-columns.md new file mode 100644 index 0000000000..a9c058ef0e --- /dev/null +++ b/.changeset/collection-list-columns.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds collection-configured custom field columns to admin content lists. Collection seeds and schema APIs can declare up to four supported fields through `admin.listColumns`; EmDash validates them when building the manifest and renders their stored values between the title and status columns. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 67e87bc7e4..3d5bb4c86a 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -48,6 +48,13 @@ export interface ContentListSort { direction: "asc" | "desc"; } +export interface ContentListColumn { + slug: string; + label: string; + kind: string; + options?: Array<{ value: string; label: string }>; +} + /** Status filter values. `"all"` clears the status filter. */ export type ContentStatusFilter = "all" | "published" | "draft" | "scheduled" | "archived"; @@ -69,6 +76,8 @@ export interface ContentListProps { collection: string; collectionLabel: string; items: ContentItem[]; + /** Validated custom-field columns from the collection manifest. */ + listColumns?: ContentListColumn[]; trashedItems?: TrashedContentItem[]; isLoading?: boolean; isTrashedLoading?: boolean; @@ -163,6 +172,7 @@ export function ContentList({ collection, collectionLabel, items, + listColumns = [], trashedItems = [], isLoading, isTrashedLoading, @@ -321,7 +331,7 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -511,6 +521,15 @@ export function ContentList({ onSortChange={onSortChange} label={t`Title`} /> + {listColumns.map((column) => ( + + {column.label} + + ))} void; showLocale?: boolean; urlPattern?: string; + listColumns: ContentListColumn[]; selectable?: boolean; selected?: boolean; onToggleSelect?: (id: string) => void; @@ -967,6 +988,7 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + listColumns, selectable, selected, onToggleSelect, @@ -996,6 +1018,9 @@ function ContentListItem({ {title} + {listColumns.map((column) => ( + + ))} + + {text} + + + ); +} + +interface ListColumnFormatOptions { + emptyLabel: string; + falseLabel: string; + locale: string; + trueLabel: string; +} + +function formatListColumnValue( + column: ContentListColumn, + value: unknown, + { emptyLabel, falseLabel, locale, trueLabel }: ListColumnFormatOptions, +): string { + if (value === null || value === undefined || value === "") return emptyLabel; + + const optionLabel = (optionValue: unknown): string => { + const text = scalarListColumnValue(optionValue); + if (text === undefined) return emptyLabel; + return column.options?.find((option) => option.value === text)?.label ?? text; + }; + + switch (column.kind) { + case "select": + return optionLabel(value); + case "multiSelect": { + let values: unknown[]; + if (Array.isArray(value)) { + values = value; + } else if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value); + values = Array.isArray(parsed) ? parsed : [value]; + } catch { + values = [value]; + } + } else { + values = [value]; + } + return values.length > 0 + ? new Intl.ListFormat(locale, { style: "short", type: "unit" }).format( + values.map(optionLabel), + ) + : emptyLabel; + } + case "boolean": + return value === true || value === 1 || value === "1" || value === "true" + ? trueLabel + : falseLabel; + case "datetime": { + const text = scalarListColumnValue(value); + if (text === undefined) return emptyLabel; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : new Intl.DateTimeFormat(locale).format(date); + } + case "number": { + if (typeof value === "number" || typeof value === "bigint") { + return new Intl.NumberFormat(locale).format(value); + } + return scalarListColumnValue(value) ?? emptyLabel; + } + case "string": + default: + return scalarListColumnValue(value) ?? emptyLabel; + } +} + +function scalarListColumnValue(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { + return String(value); + } + return undefined; +} + interface TrashedListItemProps { item: TrashedContentItem; onRestore?: (id: string) => void; diff --git a/packages/admin/src/components/index.ts b/packages/admin/src/components/index.ts index 46d6ac15c1..4c90f305d9 100644 --- a/packages/admin/src/components/index.ts +++ b/packages/admin/src/components/index.ts @@ -5,7 +5,7 @@ export { Header } from "./Header"; // Page components export { Dashboard, type DashboardProps } from "./Dashboard"; -export { ContentList, type ContentListProps } from "./ContentList"; +export { ContentList, type ContentListColumn, type ContentListProps } from "./ContentList"; export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor"; export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary"; export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal"; diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 9b0ff18ef2..e557267bcc 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -93,6 +93,7 @@ export interface AdminManifest { supports: string[]; hasSeo: boolean; urlPattern?: string; + listColumns?: string[]; fields: Record< string, { diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 1b991befa4..f0aaa8a590 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -32,6 +32,7 @@ export interface SchemaCollection { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports: string[]; source?: string; urlPattern?: string; @@ -44,6 +45,10 @@ export interface SchemaCollection { updatedAt: string; } +export interface CollectionAdminConfig { + listColumns?: string[]; +} + export interface SchemaField { id: string; collectionId: string; @@ -80,6 +85,7 @@ export interface CreateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: string[]; urlPattern?: string; hasSeo?: boolean; @@ -90,6 +96,7 @@ export interface UpdateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: string[]; urlPattern?: string; hasSeo?: boolean; diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 789e6e2d12..0b708c3eb1 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -572,6 +572,19 @@ function ContentListPage() { return ; } + const listColumns = (collectionConfig.listColumns ?? []).flatMap((slug) => { + const field = collectionConfig.fields[slug]; + if (!field) return []; + return [ + { + slug, + label: field.label ?? slug, + kind: field.kind, + options: Array.isArray(field.options) ? field.options : undefined, + }, + ]; + }); + const handleLocaleChange = (locale: string) => { // Update URL search params without full navigation void navigate({ @@ -586,6 +599,7 @@ function ContentListPage() { collection={collection} collectionLabel={collectionConfig.label} items={items} + listColumns={listColumns} trashedItems={trashedData?.items || []} isLoading={isLoading || isFetchingNextPage} isTrashedLoading={isTrashedLoading} diff --git a/packages/admin/tests/components/ContentList.test.tsx b/packages/admin/tests/components/ContentList.test.tsx index 85133a941d..eae67d7c39 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -111,6 +111,98 @@ describe("ContentList", () => { await expect.element(screen.getByText("Second")).toBeInTheDocument(); await expect.element(screen.getByText("Third")).toBeInTheDocument(); }); + + it("renders configured custom fields using their field metadata", async () => { + const items = [ + makeItem({ + data: { + title: "Support request", + ticket_number: "SUP-1042", + priority: "urgent", + labels: ["bug", "unknown"], + vip: true, + }, + }), + ]; + const screen = await render( + , + ); + + await expect.element(screen.getByText("SUP-1042")).toBeInTheDocument(); + await expect.element(screen.getByText("Urgent")).toBeInTheDocument(); + await expect.element(screen.getByText("Bug, unknown")).toBeInTheDocument(); + await expect.element(screen.getByText("Yes")).toBeInTheDocument(); + }); + + it("formats numeric, datetime, boolean, and missing custom-field values", async () => { + const openedAt = "2025-01-02T00:00:00.000Z"; + const screen = await render( + , + ); + + await expect + .element(screen.getByText(new Intl.NumberFormat("en").format(12345.67))) + .toBeInTheDocument(); + await expect + .element(screen.getByText(new Intl.DateTimeFormat("en").format(new Date(openedAt)))) + .toBeInTheDocument(); + await expect.element(screen.getByText("No", { exact: true })).toBeInTheDocument(); + await expect.element(screen.getByText("Not set")).toBeInTheDocument(); + }); + + it("does not expose unconfigured custom-field data", async () => { + const screen = await render( + , + ); + + await expect.element(screen.getByText("urgent")).toBeInTheDocument(); + expect(screen.getByText("Hidden").query()).toBeNull(); + }); }); describe("empty states", () => { @@ -666,5 +758,22 @@ describe("ContentList", () => { // The header must not render as a button — it's just a label. expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull(); }); + + it("keeps configured custom columns display-only", async () => { + const screen = await render( + , + ); + + await expect + .element(screen.getByRole("columnheader", { name: "Priority" })) + .toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Priority" }).query()).toBeNull(); + }); }); }); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..de085f4a49 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -10,6 +10,12 @@ const collectionSupportValues = z.enum(["drafts", "revisions", "preview", "sched const collectionSourcePattern = /^(template:.+|import:.+|manual|discovered|seed)$/; +const collectionAdminConfig = z.object({ + listColumns: z + .array(z.string().min(1).max(63).regex(slugPattern, "Invalid field slug format")) + .optional(), +}); + const fieldTypeValues = z.enum([ "string", "text", @@ -82,6 +88,7 @@ export const createCollectionBody = z labelSingular: z.string().optional(), description: z.string().optional(), icon: z.string().optional(), + admin: collectionAdminConfig.optional(), supports: z.array(collectionSupportValues).optional(), source: z.string().regex(collectionSourcePattern).optional(), urlPattern: z.string().optional(), @@ -95,6 +102,7 @@ export const updateCollectionBody = z labelSingular: z.string().optional(), description: z.string().optional(), icon: z.string().optional(), + admin: collectionAdminConfig.optional(), supports: z.array(collectionSupportValues).optional(), urlPattern: z.string().nullish(), hasSeo: z.boolean().optional(), @@ -175,6 +183,7 @@ export const collectionSchema = z labelSingular: z.string().nullable(), description: z.string().nullable(), icon: z.string().nullable(), + admin: collectionAdminConfig.optional(), supports: z.array(z.string()), source: z.string().nullable(), urlPattern: z.string().nullable(), diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 3ed85d1e3c..cccb74f6a6 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -31,6 +31,8 @@ export interface ManifestCollection { supports: string[]; hasSeo: boolean; urlPattern?: string; + /** Valid custom field slugs to render in the admin content list. */ + listColumns?: string[]; fields: Record< string, { diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 707a8a122c..015e8751c6 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -314,6 +314,7 @@ async function exportCollections(db: Kysely): Promise 0 ? collection.supports : undefined, urlPattern: collection.urlPattern || undefined, fields: fields.map( diff --git a/packages/core/src/database/migrations/056_collection_admin_config.ts b/packages/core/src/database/migrations/056_collection_admin_config.ts new file mode 100644 index 0000000000..22d51b26b4 --- /dev/null +++ b/packages/core/src/database/migrations/056_collection_admin_config.ts @@ -0,0 +1,16 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +/** Persist collection-level admin presentation options. */ +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_collections", "admin_config"))) { + await db.schema.alterTable("_emdash_collections").addColumn("admin_config", "text").execute(); + } +} + +export async function down(db: Kysely): Promise { + if (await columnExists(db, "_emdash_collections", "admin_config")) { + await db.schema.alterTable("_emdash_collections").dropColumn("admin_config").execute(); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 33b8eecfde..006d9459f4 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -58,6 +58,7 @@ import * as m052 from "./052_media_usage_read_index.js"; import * as m053 from "./053_plugin_mcp_tools.js"; import * as m054 from "./054_media_upload_attempts.js"; import * as m055 from "./055_content_translation_group_locale_index.js"; +import * as m056 from "./056_collection_admin_config.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -114,6 +115,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "053_plugin_mcp_tools": m053, "054_media_upload_attempts": m054, "055_content_translation_group_locale_index": m055, + "056_collection_admin_config": m056, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index b1c54418d2..3c5e6e4b93 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -300,6 +300,7 @@ export interface CollectionTable { label_singular: string | null; description: string | null; icon: string | null; + admin_config: Generated; // JSON: { listColumns?: string[] } supports: string | null; // JSON array source: string | null; search_config: string | null; // JSON: SearchConfig diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index be159cd179..2753a6cb14 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -231,6 +231,17 @@ const FIELD_TYPE_TO_KIND: Record = { const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]); const MAX_DRAFT_STAGE_ATTEMPTS = 32; +const LIST_COLUMN_FIELD_TYPES: ReadonlySet = new Set([ + "string", + "number", + "integer", + "boolean", + "datetime", + "select", + "multiSelect", +]); +const MAX_LIST_COLUMNS = 4; + /** * Sandboxed plugin entry from virtual module */ @@ -2413,12 +2424,34 @@ export class EmDashRuntime { fields[field.slug] = entry; } + const configuredListColumns = collection.admin?.listColumns ?? []; + const fieldTypes = new Map(collection.fields.map((field) => [field.slug, field.type])); + const listColumns: string[] = []; + for (const slug of configuredListColumns) { + if (listColumns.includes(slug)) continue; + const fieldType = fieldTypes.get(slug); + if (!fieldType || !LIST_COLUMN_FIELD_TYPES.has(fieldType)) { + console.warn( + `EmDash: Ignoring unsupported or unknown list column "${slug}" in collection "${collection.slug}".`, + ); + continue; + } + if (listColumns.length >= MAX_LIST_COLUMNS) { + console.warn( + `EmDash: Collection "${collection.slug}" declares more than ${MAX_LIST_COLUMNS} list columns; extra columns are ignored.`, + ); + break; + } + listColumns.push(slug); + } + manifestCollections[collection.slug] = { label: collection.label, labelSingular: collection.labelSingular || collection.label, supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, + listColumns: listColumns.length > 0 ? listColumns : undefined, fields, }; } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index b53a497f6a..91ba76ac19 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -14,6 +14,7 @@ import { FTSManager } from "../search/fts-manager.js"; import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js"; import { type Collection, + type CollectionAdminConfig, type CollectionSource, type CollectionSupport, type ColumnType, @@ -92,6 +93,22 @@ function parseSupports(raw: string | null | undefined): CollectionSupport[] { return parsed.filter(isCollectionSupport); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCollectionAdmin(raw: string | null | undefined): CollectionAdminConfig | undefined { + if (!raw) return undefined; + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) return undefined; + const listColumns = parsed.listColumns; + return { + listColumns: Array.isArray(listColumns) + ? listColumns.filter((value): value is string => typeof value === "string") + : undefined, + }; +} + /** * Error thrown when a schema operation fails */ @@ -253,6 +270,7 @@ export class SchemaRegistry { label_singular: input.labelSingular ?? null, description: input.description ?? null, icon: input.icon ?? null, + admin_config: input.admin ? JSON.stringify(input.admin) : null, supports: JSON.stringify(supports), source: input.source ?? "manual", has_seo: hasSeo ? 1 : 0, @@ -351,6 +369,7 @@ export class SchemaRegistry { label_singular: input.labelSingular ?? null, description: input.description ?? null, icon: input.icon ?? null, + admin_config: input.admin ? JSON.stringify(input.admin) : null, supports: JSON.stringify(supports), source: "seed", has_seo: hasSeo ? 1 : 0, @@ -408,6 +427,12 @@ export class SchemaRegistry { label_singular: input.labelSingular ?? existing.labelSingular ?? null, description: input.description ?? existing.description ?? null, icon: input.icon ?? existing.icon ?? null, + admin_config: + input.admin !== undefined + ? JSON.stringify(input.admin) + : existing.admin + ? JSON.stringify(existing.admin) + : null, supports: input.supports ? JSON.stringify(input.supports) : JSON.stringify(existing.supports), @@ -1238,6 +1263,7 @@ export class SchemaRegistry { labelSingular: row.label_singular ?? undefined, description: row.description ?? undefined, icon: row.icon ?? undefined, + admin: parseCollectionAdmin(row.admin_config), supports: parseSupports(row.supports), source: row.source && isCollectionSource(row.source) ? row.source : undefined, hasSeo: row.has_seo === 1, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..94ac9091a1 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -155,6 +155,12 @@ export interface FieldWidgetOptions { [key: string]: unknown; } +/** Collection-level admin presentation options. */ +export interface CollectionAdminConfig { + /** Custom field slugs to show in the content list. */ + listColumns?: string[]; +} + /** * A collection definition */ @@ -165,6 +171,7 @@ export interface Collection { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports: CollectionSupport[]; source?: CollectionSource; /** Whether this collection has SEO metadata fields enabled */ @@ -215,6 +222,7 @@ export interface CreateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: CollectionSupport[]; source?: CollectionSource; urlPattern?: string; @@ -230,6 +238,7 @@ export interface UpdateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: CollectionSupport[]; urlPattern?: string; hasSeo?: boolean; diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index efe5ed0060..fb6ca045c9 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -177,6 +177,7 @@ export async function applySeed( labelSingular: collection.labelSingular, description: collection.description, icon: collection.icon, + admin: collection.admin, supports: collection.supports || [], urlPattern: collection.urlPattern, commentsEnabled: collection.commentsEnabled, @@ -245,6 +246,7 @@ export async function applySeed( labelSingular: collection.labelSingular, description: collection.description, icon: collection.icon, + admin: collection.admin, supports: collection.supports || [], urlPattern: collection.urlPattern, commentsEnabled: collection.commentsEnabled, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index a106bfcd51..5d7e97f140 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -5,7 +5,7 @@ * collections, fields, menus, settings, taxonomies, redirects, widget areas, and optional sample content. */ -import type { FieldType } from "../schema/types.js"; +import type { CollectionAdminConfig, FieldType } from "../schema/types.js"; import type { SiteSettings } from "../settings/types.js"; import type { Storage } from "../storage/types.js"; @@ -72,6 +72,7 @@ export interface SeedCollection { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[]; urlPattern?: string; /** Enable comments on this collection */ diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts index d4396d6c14..55ef5d4a18 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -108,6 +108,35 @@ export function validateSeed(data: unknown): ValidationResult { errors.push(`${prefix}: label is required`); } + const declaredFieldSlugs = new Set( + Array.isArray(collection.fields) + ? collection.fields.flatMap((field) => + isRecord(field) && typeof field.slug === "string" ? [field.slug] : [], + ) + : [], + ); + + if (collection.admin !== undefined) { + if (!isRecord(collection.admin)) { + errors.push(`${prefix}.admin: must be an object`); + } else if (collection.admin.listColumns !== undefined) { + if (!Array.isArray(collection.admin.listColumns)) { + errors.push(`${prefix}.admin.listColumns: must be an array`); + } else { + for (let j = 0; j < collection.admin.listColumns.length; j++) { + const slug = collection.admin.listColumns[j]; + if (typeof slug !== "string" || !COLLECTION_FIELD_SLUG_PATTERN.test(slug)) { + errors.push(`${prefix}.admin.listColumns[${j}]: must be a valid field slug`); + } else if (!declaredFieldSlugs.has(slug)) { + errors.push( + `${prefix}.admin.listColumns[${j}]: references unknown field "${slug}"`, + ); + } + } + } + } + } + // Validate fields if (!Array.isArray(collection.fields)) { errors.push(`${prefix}.fields: must be an array`); diff --git a/packages/core/tests/database/migrations.test.ts b/packages/core/tests/database/migrations.test.ts index 3afbfed073..892aa262f5 100644 --- a/packages/core/tests/database/migrations.test.ts +++ b/packages/core/tests/database/migrations.test.ts @@ -136,6 +136,7 @@ describe("Database Migrations", () => { expect(columns).toContain("label_singular"); expect(columns).toContain("description"); expect(columns).toContain("icon"); + expect(columns).toContain("admin_config"); expect(columns).toContain("supports"); expect(columns).toContain("source"); expect(columns).toContain("created_at"); diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index df9af0f17a..de6458f4ce 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -142,6 +142,7 @@ describe("Database Migrations (Integration)", () => { "053_plugin_mcp_tools", "054_media_upload_attempts", "055_content_translation_group_locale_index", + "056_collection_admin_config", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/cli/seed-commands.test.ts b/packages/core/tests/unit/cli/seed-commands.test.ts index 5ac70d9eab..4fca3708eb 100644 --- a/packages/core/tests/unit/cli/seed-commands.test.ts +++ b/packages/core/tests/unit/cli/seed-commands.test.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { exportSeed } from "../../../src/cli/commands/export-seed.js"; import { createDatabase } from "../../../src/database/connection.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import { applySeed } from "../../../src/seed/apply.js"; @@ -213,6 +214,39 @@ describe("CLI Seed Commands", () => { }); describe("export-seed output", () => { + it("preserves collection list columns through apply and export", async () => { + const dbPath = join(tempDir, "admin-config.db"); + const db = createDatabase({ url: `file:${dbPath}` }); + + try { + await runMigrations(db); + await applySeed(db, { + version: "1", + collections: [ + { + slug: "tickets", + label: "Tickets", + admin: { listColumns: ["ticket_number", "priority"] }, + fields: [ + { slug: "ticket_number", label: "Ticket number", type: "string" }, + { slug: "priority", label: "Priority", type: "select" }, + ], + }, + ], + }); + + const exported = await exportSeed(db); + const tickets = exported.collections?.find((collection) => collection.slug === "tickets"); + + expect(tickets?.admin).toEqual({ + listColumns: ["ticket_number", "priority"], + }); + expect(validateSeed(exported)).toMatchObject({ valid: true, errors: [] }); + } finally { + await db.destroy(); + } + }); + it("should produce valid seed from exported data", async () => { const dbPath = join(tempDir, "test.db"); const db = createDatabase({ url: `file:${dbPath}` }); diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index ee9e379005..17284d83f8 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -15,7 +15,7 @@ */ import type { Kysely } from "kysely"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; import type { Database } from "../../../src/database/types.js"; @@ -72,6 +72,7 @@ describe("EmDashRuntime.getManifest()", () => { }); afterEach(async () => { + vi.restoreAllMocks(); await teardownTestDatabase(db); }); @@ -158,4 +159,66 @@ describe("EmDashRuntime.getManifest()", () => { expect(manifest.collections[`coll_${i}`]?.fields.title?.kind).toBe("string"); } }); + + it("publishes only supported, existing list columns and caps them at four", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "tickets", + label: "Tickets", + admin: { + listColumns: [ + "ticket_number", + "ticket_number", + "details", + "missing", + "priority", + "urgent", + "queue", + "opened_at", + ], + }, + }); + await registry.createField("tickets", { + slug: "ticket_number", + label: "Ticket number", + type: "string", + }); + await registry.createField("tickets", { + slug: "details", + label: "Details", + type: "json", + }); + await registry.createField("tickets", { + slug: "priority", + label: "Priority", + type: "select", + }); + await registry.createField("tickets", { + slug: "urgent", + label: "Urgent", + type: "boolean", + }); + await registry.createField("tickets", { + slug: "queue", + label: "Queue", + type: "string", + }); + await registry.createField("tickets", { + slug: "opened_at", + label: "Opened", + type: "datetime", + }); + + const runtime = buildRuntime(db); + const manifest = await runtime.getManifest(); + + expect(manifest.collections.tickets?.listColumns).toEqual([ + "ticket_number", + "priority", + "urgent", + "queue", + ]); + expect(warn).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 0991d0d9ce..0159b97425 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -128,6 +128,19 @@ describe("SchemaRegistry", () => { expect(updated.supports).toEqual(["drafts"]); }); + it("persists collection admin list columns", async () => { + const created = await registry.createCollection({ + slug: "tickets", + label: "Tickets", + admin: { listColumns: ["ticket_number", "priority"] }, + }); + + expect(created.admin?.listColumns).toEqual(["ticket_number", "priority"]); + + const updated = await registry.updateCollection("tickets", { label: "Support tickets" }); + expect(updated.admin?.listColumns).toEqual(["ticket_number", "priority"]); + }); + it("should throw when updating non-existent collection", async () => { await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow( SchemaError, diff --git a/packages/core/tests/unit/seed/validate.test.ts b/packages/core/tests/unit/seed/validate.test.ts index bca4c0e019..c793879baa 100644 --- a/packages/core/tests/unit/seed/validate.test.ts +++ b/packages/core/tests/unit/seed/validate.test.ts @@ -197,6 +197,25 @@ describe("validateSeed", () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); + + it("should reject list columns that do not reference collection fields", () => { + const result = validateSeed({ + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + admin: { listColumns: ["priority"] }, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'collections[0].admin.listColumns[0]: references unknown field "priority"', + ); + }); }); describe("taxonomy validation", () => { From 109a19ecee4adcabd6ebd553e1efc3bca1fabf57 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:47:01 +0200 Subject: [PATCH 2/9] fix(admin): use the .js specifier on the content list export --- packages/admin/src/components/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin/src/components/index.ts b/packages/admin/src/components/index.ts index 4c90f305d9..a98f5a2b78 100644 --- a/packages/admin/src/components/index.ts +++ b/packages/admin/src/components/index.ts @@ -5,7 +5,7 @@ export { Header } from "./Header"; // Page components export { Dashboard, type DashboardProps } from "./Dashboard"; -export { ContentList, type ContentListColumn, type ContentListProps } from "./ContentList"; +export { ContentList, type ContentListColumn, type ContentListProps } from "./ContentList.js"; export { ContentEditor, type ContentEditorProps, type FieldDescriptor } from "./ContentEditor"; export { MediaLibrary, type MediaLibraryProps } from "./MediaLibrary"; export { MediaPickerModal, type MediaPickerModalProps } from "./MediaPickerModal"; From 5692eebb530fcc1dfd692e8721a9f3853f824135 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:27:45 +0200 Subject: [PATCH 3/9] feat(content): support indexed custom field sorting --- .changeset/indexed-custom-field-sorting.md | 6 + .../src/content/docs/concepts/collections.mdx | 10 + .../content/docs/reference/field-types.mdx | 6 + .../src/content/docs/reference/mcp-server.mdx | 1 + docs/src/content/docs/themes/seed-files.mdx | 1 + packages/admin/src/components/FieldEditor.tsx | 53 ++++-- packages/admin/src/lib/api/schema.ts | 3 + .../components/ContentTypeEditor.test.tsx | 1 + .../tests/components/FieldEditor.test.tsx | 4 + packages/core/src/api/schemas/schema.ts | 3 + packages/core/src/cli/commands/export-seed.ts | 1 + .../migrations/057_indexed_content_fields.ts | 36 ++++ .../core/src/database/migrations/runner.ts | 2 + .../core/src/database/repositories/content.ts | 172 +++++++++++++++--- packages/core/src/database/types.ts | 1 + packages/core/src/mcp/server.ts | 2 + packages/core/src/schema/registry.ts | 78 +++++++- packages/core/src/schema/types.ts | 26 +++ packages/core/src/seed/apply.ts | 3 + packages/core/src/seed/types.ts | 1 + .../core/tests/database/migrations.test.ts | 48 ++++- .../database/repositories/content.test.ts | 53 ++++++ .../integration/database/migrations.test.ts | 1 + .../core/tests/unit/schema/registry.test.ts | 43 +++++ 24 files changed, 520 insertions(+), 35 deletions(-) create mode 100644 .changeset/indexed-custom-field-sorting.md create mode 100644 packages/core/src/database/migrations/057_indexed_content_fields.ts diff --git a/.changeset/indexed-custom-field-sorting.md b/.changeset/indexed-custom-field-sorting.md new file mode 100644 index 0000000000..9e2ea31d4a --- /dev/null +++ b/.changeset/indexed-custom-field-sorting.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds opt-in database indexes for scalar custom fields and stable cursor pagination when ordering content lists by those fields. diff --git a/docs/src/content/docs/concepts/collections.mdx b/docs/src/content/docs/concepts/collections.mdx index 6e70f43d1a..da9cbc0e89 100644 --- a/docs/src/content/docs/concepts/collections.mdx +++ b/docs/src/content/docs/concepts/collections.mdx @@ -216,6 +216,7 @@ Every field supports these properties: | `type` | `FieldType` | One of the 16 field types | | `required` | `boolean` | Whether the field must have a value | | `unique` | `boolean` | Whether values must be unique across entries | +| `indexed` | `boolean` | Allow efficient sorting by this field | | `defaultValue` | `unknown` | Default value for new entries | | `validation` | `object` | Type-specific validation rules | | `widget` | `string` | Custom widget identifier | @@ -228,6 +229,15 @@ Every field supports these properties: `version`, `live_revision_id`, `draft_revision_id`, `terms`, `bylines`, `byline`. +Set `indexed: true` when a collection query needs to use a custom field in `orderBy`. Indexes are +supported for `string`, `url`, `number`, `integer`, `boolean`, `datetime`, `select`, `reference`, +and `slug` fields. EmDash rejects indexed JSON, rich content, and other non-scalar field types. + + + ## Validation rules The `validation` object varies by field type. Its full shape is: diff --git a/docs/src/content/docs/reference/field-types.mdx b/docs/src/content/docs/reference/field-types.mdx index 7ffad4e700..6a9aba2ef6 100644 --- a/docs/src/content/docs/reference/field-types.mdx +++ b/docs/src/content/docs/reference/field-types.mdx @@ -386,12 +386,18 @@ All fields support these common properties: | `type` | `FieldType` | Field type (required) | | `required` | `boolean` | Require a value (default: false) | | `unique` | `boolean` | Enforce uniqueness (default: false) | +| `indexed` | `boolean` | Enable indexed custom-field sorting | | `defaultValue` | `unknown` | Default value for new entries | | `validation` | `object` | Type-specific validation rules | | `widget` | `string` | Custom widget override | | `options` | `object` | Widget configuration | | `sortOrder` | `number` | Display order in admin | +`indexed` is available for scalar fields: `string`, `url`, `number`, `integer`, `boolean`, +`datetime`, `select`, `reference`, and `slug`. An indexed field can be passed as the `orderBy` +field in content list queries. Avoid indexing fields that are not used for sorting because every +index adds storage and write overhead. + ## Reserved field slugs These slugs are reserved and cannot be used: diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index 68b8eb45c2..3b387b4345 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -351,6 +351,7 @@ Add a new field to a collection's schema. This adds a column to the database tab | `validation` | `object` | No | Constraints: `min`, `max`, `minLength`, `maxLength`, `pattern`, `options` | | `options` | `object` | No | Widget config: `collection` (for references), `rows` (for textarea) | | `searchable` | `boolean` | No | Include in full-text search index | +| `indexed` | `boolean` | No | Enable indexed sorting by this field | | `translatable` | `boolean` | No | Whether this field is translatable (default true) | Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, `reference`, `json`, `slug`. diff --git a/docs/src/content/docs/themes/seed-files.mdx b/docs/src/content/docs/themes/seed-files.mdx index 9ce91bf6b5..99072bf38b 100644 --- a/docs/src/content/docs/themes/seed-files.mdx +++ b/docs/src/content/docs/themes/seed-files.mdx @@ -132,6 +132,7 @@ Each collection definition creates a content type in the database: | `type` | `string` | Yes | Field type | | `required` | `boolean` | No | Validation: field must have a value | | `unique` | `boolean` | No | Validation: value must be unique | +| `indexed` | `boolean` | No | Enable indexed sorting by this field | | `defaultValue` | `any` | No | Default value for new entries | | `validation` | `object` | No | Additional validation rules | | `widget` | `string` | No | Admin UI widget override | diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 435ea9dddb..c34a94dd08 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -32,6 +32,32 @@ import { AllowedTypesEditor } from "./AllowedTypesEditor"; const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g; const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g; +const SEARCHABLE_FIELD_TYPES = new Set([ + "string", + "text", + "portableText", + "slug", + "url", +]); +const INDEXABLE_FIELD_TYPES = new Set([ + "string", + "url", + "number", + "integer", + "boolean", + "datetime", + "select", + "reference", + "slug", +]); + +function isSearchableFieldType(type: FieldType | null): type is FieldType { + return type !== null && SEARCHABLE_FIELD_TYPES.has(type); +} + +function isIndexableFieldType(type: FieldType | null): type is FieldType { + return type !== null && INDEXABLE_FIELD_TYPES.has(type); +} // ============================================================================ // Types @@ -67,6 +93,7 @@ interface FieldFormState { required: boolean; unique: boolean; searchable: boolean; + indexed: boolean; minLength: string; maxLength: string; min: string; @@ -89,6 +116,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { required: field.required, unique: field.unique, searchable: field.searchable, + indexed: field.indexed ?? false, minLength: field.validation?.minLength?.toString() ?? "", maxLength: field.validation?.maxLength?.toString() ?? "", min: field.validation?.min?.toString() ?? "", @@ -111,6 +139,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { required: false, unique: false, searchable: false, + indexed: false, minLength: "", maxLength: "", min: "", @@ -138,7 +167,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie } }, [open, field]); - const { step, selectedType, slug, label, required, unique, searchable } = formState; + const { step, selectedType, slug, label, required, unique, searchable, indexed } = formState; const { minLength, maxLength, min, max, pattern, options } = formState; const setField = (key: K, value: FieldFormState[K]) => setFormState((prev) => ({ ...prev, [key]: value })); @@ -312,12 +341,8 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie } // Only include searchable for text-based fields - const isSearchableType = - selectedType === "string" || - selectedType === "text" || - selectedType === "portableText" || - selectedType === "slug" || - selectedType === "url"; + const isSearchableType = isSearchableFieldType(selectedType); + const isIndexableType = isIndexableFieldType(selectedType); const input: CreateFieldInput = { slug, @@ -326,6 +351,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie required, unique, searchable: isSearchableType ? searchable : undefined, + indexed: isIndexableType ? indexed : undefined, validation: Object.keys(validation).length > 0 ? validation : null, }; @@ -441,17 +467,20 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie onCheckedChange={(checked) => setField("unique", checked)} label={{t`Unique`}} /> - {(selectedType === "string" || - selectedType === "text" || - selectedType === "portableText" || - selectedType === "slug" || - selectedType === "url") && ( + {isSearchableFieldType(selectedType) && ( setField("searchable", checked)} label={{t`Searchable`}} /> )} + {isIndexableFieldType(selectedType) && ( + setField("indexed", checked)} + label={{t`Indexed`}} + /> + )}
{/* Type-specific validation */} diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index f0aaa8a590..f050d846ad 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -59,6 +59,7 @@ export interface SchemaField { required: boolean; unique: boolean; searchable: boolean; + indexed: boolean; defaultValue?: unknown; validation?: { min?: number; @@ -113,6 +114,7 @@ export interface CreateFieldInput { required?: boolean; unique?: boolean; searchable?: boolean; + indexed?: boolean; defaultValue?: unknown; validation?: { min?: number; @@ -132,6 +134,7 @@ export interface UpdateFieldInput { required?: boolean; unique?: boolean; searchable?: boolean; + indexed?: boolean; defaultValue?: unknown; validation?: { min?: number; diff --git a/packages/admin/tests/components/ContentTypeEditor.test.tsx b/packages/admin/tests/components/ContentTypeEditor.test.tsx index 30420a1943..0129298d4b 100644 --- a/packages/admin/tests/components/ContentTypeEditor.test.tsx +++ b/packages/admin/tests/components/ContentTypeEditor.test.tsx @@ -42,6 +42,7 @@ function makeField(overrides: Partial = {}): SchemaField { required: false, unique: false, searchable: false, + indexed: false, sortOrder: 0, createdAt: "2025-01-01T00:00:00Z", ...overrides, diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx index 007e2e1993..b0654ebcda 100644 --- a/packages/admin/tests/components/FieldEditor.test.tsx +++ b/packages/admin/tests/components/FieldEditor.test.tsx @@ -42,6 +42,7 @@ function makeField(overrides: Partial = {}): SchemaField { required: true, unique: false, searchable: true, + indexed: false, sortOrder: 0, createdAt: new Date().toISOString(), ...overrides, @@ -127,6 +128,7 @@ describe("FieldEditor", () => { it("shows searchable checkbox for string type", async () => { const screen = await render(); await expect.element(screen.getByText("Searchable")).toBeInTheDocument(); + await expect.element(screen.getByText("Indexed")).toBeInTheDocument(); }); it("shows min/max length validation for string type", async () => { @@ -162,6 +164,7 @@ describe("FieldEditor", () => { const screen = await render(); await expect.element(screen.getByLabelText("Min Value")).toBeInTheDocument(); await expect.element(screen.getByLabelText("Max Value")).toBeInTheDocument(); + await expect.element(screen.getByText("Indexed")).toBeInTheDocument(); }); it("does not show searchable for number type", async () => { @@ -201,6 +204,7 @@ describe("FieldEditor", () => { it("shows searchable checkbox for text type", async () => { const screen = await render(); await expect.element(screen.getByText("Searchable")).toBeInTheDocument(); + expect(screen.getByText("Indexed").query()).toBeNull(); }); }); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index de085f4a49..176228f71f 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -126,6 +126,7 @@ export const createFieldBody = z options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), searchable: z.boolean().optional(), + indexed: z.boolean().optional(), translatable: z.boolean().optional(), }) .meta({ id: "CreateFieldBody" }); @@ -142,6 +143,7 @@ export const updateFieldBody = z options: fieldWidgetOptions, sortOrder: z.number().int().min(0).optional(), searchable: z.boolean().optional(), + indexed: z.boolean().optional(), translatable: z.boolean().optional(), }) .meta({ id: "UpdateFieldBody" }); @@ -208,6 +210,7 @@ export const fieldSchema = z options: z.record(z.string(), z.unknown()).nullable(), sortOrder: z.number().int(), searchable: z.boolean(), + indexed: z.boolean(), translatable: z.boolean(), createdAt: z.string(), updatedAt: z.string(), diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 015e8751c6..d7ceecab74 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -325,6 +325,7 @@ async function exportCollections(db: Kysely): Promise): Promise { + if (!(await columnExists(db, "_emdash_fields", "indexed"))) { + await db.schema + .alterTable("_emdash_fields") + .addColumn("indexed", "integer", (column) => column.notNull().defaultTo(0)) + .execute(); + } +} + +export async function down(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_fields", "indexed"))) { + return; + } + + const indexedFields = await sql<{ id: string }>` + SELECT id FROM _emdash_fields WHERE indexed = 1 + `.execute(db); + + for (const field of indexedFields.rows) { + if (!FIELD_ID_PATTERN.test(field.id)) { + throw new Error(`Invalid indexed field id "${field.id}"`); + } + await sql` + DROP INDEX IF EXISTS ${sql.ref(`idx_cf_${field.id.toLowerCase()}`)} + `.execute(db); + } + + await db.schema.alterTable("_emdash_fields").dropColumn("indexed").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 006d9459f4..75dcf53e57 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -59,6 +59,7 @@ import * as m053 from "./053_plugin_mcp_tools.js"; import * as m054 from "./054_media_upload_attempts.js"; import * as m055 from "./055_content_translation_group_locale_index.js"; import * as m056 from "./056_collection_admin_config.js"; +import * as m057 from "./057_indexed_content_fields.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -116,6 +117,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "054_media_upload_attempts": m054, "055_content_translation_group_locale_index": m055, "056_collection_admin_config": m056, + "057_indexed_content_fields": m057, }); /** 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 7d84859bdd..fadbd91a11 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -20,6 +20,7 @@ import type { import { ContentMutationConflictError, EmDashValidationError, + InvalidCursorError, ScheduledNotDueError, encodeCursor, decodeCursor, @@ -85,6 +86,51 @@ function isConfirmedStatementFailure(error: unknown): boolean { return typeof code === "string" && (code.startsWith("SQLITE_") || SQLSTATE_PATTERN.test(code)); } +interface ResolvedOrderField { + column: string; + indexedCustomField: boolean; +} + +type IndexedOrderValue = string | number | null; + +interface IndexedFieldCursorPayload { + version: 1; + field: string; + value: IndexedOrderValue; +} + +function encodeIndexedFieldCursor(field: string, value: IndexedOrderValue, id: string): string { + const payload: IndexedFieldCursorPayload = { version: 1, field, value }; + return encodeCursor(JSON.stringify(payload), id); +} + +function decodeIndexedFieldCursor( + cursor: string, + field: string, +): { value: IndexedOrderValue; id: string } { + const { orderValue, id } = decodeCursor(cursor); + let payload: unknown; + try { + payload = JSON.parse(orderValue); + } catch { + throw new InvalidCursorError(cursor); + } + + if (payload === null || typeof payload !== "object") { + throw new InvalidCursorError(cursor); + } + const candidate = payload as Partial; + const validValue = + candidate.value === null || + typeof candidate.value === "string" || + typeof candidate.value === "number"; + if (candidate.version !== 1 || candidate.field !== field || !validValue) { + throw new InvalidCursorError(cursor); + } + + return { value: candidate.value as IndexedOrderValue, id }; +} + /** * Whitelist mapping a public date-filter field to its physical column. Keeping * this separate from `mapOrderField` makes the filterable set explicit and @@ -557,7 +603,8 @@ export class ContentRepository { // Determine ordering const orderField = options.orderBy?.field || "createdAt"; const orderDirection = options.orderBy?.direction || "desc"; - const dbField = this.mapOrderField(orderField); + const resolvedOrderField = await this.resolveOrderField(type, orderField); + const dbField = resolvedOrderField.column; // Validate order direction to prevent injection const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC"; @@ -589,26 +636,68 @@ export class ContentRepository { // on malformed input; let it propagate so handlers surface a // structured INVALID_CURSOR rather than silently returning page 1. if (options.cursor) { - const { orderValue, id: cursorId } = decodeCursor(options.cursor); - - if (safeOrderDirection === "DESC") { - query = query.where((eb) => - eb.or([ - eb(dbField as any, "<", orderValue), - eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]), - ]), - ); + if (resolvedOrderField.indexedCustomField) { + const { value, id: cursorId } = decodeIndexedFieldCursor(options.cursor, orderField); + const isPresent = sql`${sql.ref(dbField)} IS NOT NULL`; + const falseLiteral = sql`FALSE`; + const trueLiteral = sql`TRUE`; + if (safeOrderDirection === "ASC" && value === null) { + query = query.where(sql` + (${isPresent}) > ${falseLiteral} + OR ((${isPresent}) = ${falseLiteral} AND ${sql.ref("id")} > ${cursorId}) + `); + } else if (safeOrderDirection === "DESC" && value === null) { + query = query.where(sql` + (${isPresent}) = ${falseLiteral} AND ${sql.ref("id")} < ${cursorId} + `); + } else if (safeOrderDirection === "ASC") { + query = query.where(sql` + (${isPresent}) = ${trueLiteral} + AND ( + ${sql.ref(dbField)} > ${value} + OR (${sql.ref(dbField)} = ${value} AND ${sql.ref("id")} > ${cursorId}) + ) + `); + } else { + query = query.where(sql` + (${isPresent}) < ${trueLiteral} + OR ( + (${isPresent}) = ${trueLiteral} + AND ( + ${sql.ref(dbField)} < ${value} + OR (${sql.ref(dbField)} = ${value} AND ${sql.ref("id")} < ${cursorId}) + ) + ) + `); + } } else { - query = query.where((eb) => - eb.or([ - eb(dbField as any, ">", orderValue), - eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]), - ]), - ); + const { orderValue, id: cursorId } = decodeCursor(options.cursor); + + if (safeOrderDirection === "DESC") { + query = query.where((eb) => + eb.or([ + eb(dbField as any, "<", orderValue), + eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]), + ]), + ); + } else { + query = query.where((eb) => + eb.or([ + eb(dbField as any, ">", orderValue), + eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]), + ]), + ); + } } } // Apply ordering and limit + if (resolvedOrderField.indexedCustomField) { + query = query.orderBy( + sql`${sql.ref(dbField)} IS NOT NULL`, + safeOrderDirection === "ASC" ? "asc" : "desc", + ); + } query = query .orderBy(dbField as any, safeOrderDirection === "ASC" ? "asc" : "desc") .orderBy("id", safeOrderDirection === "ASC" ? "asc" : "desc") @@ -629,11 +718,26 @@ export class ContentRepository { if (hasMore && items.length > 0) { const lastRow = items.at(-1) as Record; const lastOrderValue = lastRow[dbField]; - const orderStr = - typeof lastOrderValue === "string" || typeof lastOrderValue === "number" - ? String(lastOrderValue) - : ""; - mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id)); + if (resolvedOrderField.indexedCustomField) { + if ( + lastOrderValue !== null && + typeof lastOrderValue !== "string" && + typeof lastOrderValue !== "number" + ) { + throw new EmDashValidationError(`Invalid indexed value for order field: ${orderField}`); + } + mappedResult.nextCursor = encodeIndexedFieldCursor( + orderField, + lastOrderValue as IndexedOrderValue, + String(lastRow.id), + ); + } else { + const orderStr = + typeof lastOrderValue === "string" || typeof lastOrderValue === "number" + ? String(lastOrderValue) + : ""; + mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id)); + } } return mappedResult; @@ -1712,4 +1816,30 @@ export class ContentRepository { } return mapped; } + + private async resolveOrderField(type: string, field: string): Promise { + try { + return { column: this.mapOrderField(field), indexedCustomField: false }; + } catch (error) { + if (!(error instanceof EmDashValidationError)) throw error; + } + + const customField = await this.db + .selectFrom("_emdash_fields as field") + .innerJoin("_emdash_collections as collection", "collection.id", "field.collection_id") + .where("collection.slug", "=", type) + .where("field.slug", "=", field) + .where("field.indexed", "=", 1) + .select("field.slug") + .executeTakeFirst(); + + if (!customField) { + throw new EmDashValidationError( + `Invalid order field: ${field}. Custom fields must be indexed before sorting.`, + ); + } + + validateIdentifier(customField.slug, "content order field"); + return { column: customField.slug, indexedCustomField: true }; + } } diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 3c5e6e4b93..4f2d5f7545 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -341,6 +341,7 @@ export interface FieldTable { options: string | null; // JSON sort_order: number; searchable: Generated; // boolean as 0/1, defaults to 0 + indexed: Generated; // boolean as 0/1, defaults to 0 translatable: Generated; // boolean as 0/1, defaults to 1 created_at: Generated; } diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index e25338de24..b38679cca7 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -1805,6 +1805,7 @@ export function createMcpServer( .boolean() .optional() .describe("Include in full-text search index (default false)"), + indexed: z.boolean().optional().describe("Create a physical index for structured sorting"), translatable: z .boolean() .optional() @@ -1831,6 +1832,7 @@ export function createMcpServer( validation: args.validation, options: args.options, searchable: args.searchable, + indexed: args.indexed, translatable: args.translatable, }); return jsonResult(field); diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 91ba76ac19..d97861d837 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -26,6 +26,7 @@ import { type CollectionWithFields, type FieldType, FIELD_TYPE_TO_COLUMN, + isIndexableFieldType, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS, } from "./types.js"; @@ -36,6 +37,7 @@ const EC_PREFIX_PATTERN = /^ec_/; const SINGLE_QUOTE_PATTERN = /'/g; const UNDERSCORE_PATTERN = /_/g; const WORD_BOUNDARY_PATTERN = /\b\w/g; +const FIELD_ID_PATTERN = /^[0-9A-Z]{26}$/; /** Valid column types for runtime validation */ const COLUMN_TYPES: ReadonlySet = new Set(["TEXT", "REAL", "INTEGER", "JSON"]); @@ -70,10 +72,19 @@ const VALID_COLLECTION_SUPPORTS: ReadonlySet = new Set(); for (const field of fields) { this.validateSlug(field.slug, "field"); + assertIndexableField(field.type, field.indexed, field.slug); if (RESERVED_FIELD_SLUGS.includes(field.slug)) { throw new SchemaError(`Field slug "${field.slug}" is reserved`, "RESERVED_SLUG"); } @@ -353,6 +365,7 @@ export class SchemaRegistry { options: field.options ? JSON.stringify(field.options) : null, sort_order: sortOrder, searchable: field.searchable ? 1 : 0, + indexed: field.indexed ? 1 : 0, translatable: field.translatable === false ? 0 : 1, }; }); @@ -381,6 +394,12 @@ export class SchemaRegistry { await this.createContentTable(input.slug, trx, fields); + for (const field of fieldRows) { + if (field.indexed === 1) { + await this.createFieldIndex(input.slug, field.id, field.slug, trx); + } + } + for (const fieldBatch of chunks(fieldRows, SEED_FIELD_INSERT_BATCH_SIZE)) { await trx.insertInto("_emdash_fields").values(fieldBatch).execute(); } @@ -596,6 +615,7 @@ export class SchemaRegistry { const id = ulid(); const columnType = FIELD_TYPE_TO_COLUMN[input.type]; + assertIndexableField(input.type, input.indexed, input.slug); // Get max sort order const maxSort = await this.db @@ -628,6 +648,7 @@ export class SchemaRegistry { options: input.options ? JSON.stringify(input.options) : null, sort_order: sortOrder, searchable: input.searchable ? 1 : 0, + indexed: input.indexed ? 1 : 0, translatable: input.translatable === false ? 0 : 1, }) .execute(); @@ -645,6 +666,10 @@ export class SchemaRegistry { trx, ); + if (input.indexed) { + await this.createFieldIndex(collectionSlug, id, input.slug, trx); + } + // Read the created field via trx (not this.db) to avoid connection mutex deadlock const fieldRow = await trx .selectFrom("_emdash_fields") @@ -727,6 +752,9 @@ export class SchemaRegistry { nextColumnType = newColumnType; } + const nextIndexed = input.indexed ?? field.indexed; + assertIndexableField(nextType, nextIndexed, fieldSlug); + let schemaMutated = false; try { const updatedField = await withTransaction(this.db, async (trx) => { @@ -747,6 +775,7 @@ export class SchemaRegistry { : field.searchable ? 1 : 0, + indexed: nextIndexed ? 1 : 0, translatable: input.translatable !== undefined ? input.translatable @@ -774,6 +803,14 @@ export class SchemaRegistry { .execute(); schemaMutated = true; + if (nextIndexed !== field.indexed) { + if (nextIndexed) { + await this.createFieldIndex(collectionSlug, field.id, fieldSlug, trx); + } else { + await this.dropFieldIndex(field.id, trx); + } + } + // Read the updated field via trx (not this.db) to avoid connection mutex deadlock const updatedRow = await trx .selectFrom("_emdash_fields") @@ -886,6 +923,10 @@ export class SchemaRegistry { await this.syncSearchState(collectionSlug, trx); } + if (field.indexed) { + await this.dropFieldIndex(field.id, trx); + } + // Drop column from content table — safe now because FTS triggers are gone await this.dropColumn(collectionSlug, fieldSlug, trx); }); @@ -1064,6 +1105,40 @@ export class SchemaRegistry { `.execute(conn); } + private getFieldIndexName(fieldId: string): string { + if (!FIELD_ID_PATTERN.test(fieldId)) { + throw new SchemaError(`Invalid field id "${fieldId}"`, "INVALID_FIELD_ID"); + } + return `idx_cf_${fieldId.toLowerCase()}`; + } + + private async createFieldIndex( + collectionSlug: string, + fieldId: string, + fieldSlug: string, + db?: Kysely, + ): Promise { + const conn = db ?? this.db; + const tableName = this.getTableName(collectionSlug); + const columnName = this.getColumnName(fieldSlug); + const indexName = this.getFieldIndexName(fieldId); + + await sql` + CREATE INDEX ${sql.ref(indexName)} + ON ${sql.ref(tableName)} ( + (${sql.ref(columnName)} IS NOT NULL), + ${sql.ref(columnName)}, + id + ) + WHERE deleted_at IS NULL + `.execute(conn); + } + + private async dropFieldIndex(fieldId: string, db?: Kysely): Promise { + const conn = db ?? this.db; + await sql`DROP INDEX IF EXISTS ${sql.ref(this.getFieldIndexName(fieldId))}`.execute(conn); + } + /** * Add a column to a content table */ @@ -1299,6 +1374,7 @@ export class SchemaRegistry { options: row.options ? JSON.parse(row.options) : undefined, sortOrder: row.sort_order, searchable: row.searchable === 1, + indexed: row.indexed === 1, translatable: row.translatable !== 0, createdAt: row.created_at, }; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 94ac9091a1..b7463f2c97 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -48,6 +48,26 @@ export const FIELD_TYPES: readonly FieldType[] = [ "repeater", ] as const; +/** + * Scalar field types that can be backed by a content-list query index. + * Complex JSON values and large free-form text are intentionally excluded. + */ +export const INDEXABLE_FIELD_TYPES: ReadonlySet = new Set([ + "string", + "url", + "number", + "integer", + "boolean", + "datetime", + "select", + "reference", + "slug", +]); + +export function isIndexableFieldType(type: FieldType): boolean { + return INDEXABLE_FIELD_TYPES.has(type); +} + /** * SQLite column types that map from field types */ @@ -208,6 +228,8 @@ export interface Field { options?: FieldWidgetOptions; sortOrder: number; searchable: boolean; + /** Whether this field has a physical index for structured list queries. */ + indexed: boolean; /** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */ translatable: boolean; createdAt: string; @@ -264,6 +286,8 @@ export interface CreateFieldInput { sortOrder?: number; /** Whether this field should be indexed for search */ searchable?: boolean; + /** Create a physical index for structured sorting. */ + indexed?: boolean; /** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */ translatable?: boolean; } @@ -292,6 +316,8 @@ export interface UpdateFieldInput { sortOrder?: number; /** Whether this field should be indexed for search */ searchable?: boolean; + /** Create or remove the physical index used by structured sorting. */ + indexed?: boolean; /** Whether this field is translatable (default true). Non-translatable fields are synced across locales. */ translatable?: boolean; } diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index fb6ca045c9..4b019edb4d 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -194,6 +194,7 @@ export async function applySeed( required: field.required || false, unique: field.unique || false, searchable: field.searchable || false, + indexed: field.indexed || false, defaultValue: field.defaultValue, validation: field.validation, widget: field.widget, @@ -208,6 +209,7 @@ export async function applySeed( required: field.required || false, unique: field.unique || false, searchable: field.searchable || false, + indexed: field.indexed || false, defaultValue: field.defaultValue, validation: field.validation, widget: field.widget, @@ -232,6 +234,7 @@ export async function applySeed( required: field.required || false, unique: field.unique || false, searchable: field.searchable || false, + indexed: field.indexed || false, defaultValue: field.defaultValue, validation: field.validation, widget: field.widget, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index 5d7e97f140..3bf574fb1a 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -90,6 +90,7 @@ export interface SeedField { required?: boolean; unique?: boolean; searchable?: boolean; + indexed?: boolean; defaultValue?: unknown; validation?: Record; widget?: string; diff --git a/packages/core/tests/database/migrations.test.ts b/packages/core/tests/database/migrations.test.ts index 892aa262f5..ec77e20268 100644 --- a/packages/core/tests/database/migrations.test.ts +++ b/packages/core/tests/database/migrations.test.ts @@ -1,7 +1,8 @@ -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createDatabase } from "../../src/database/connection.js"; +import * as indexedContentFields from "../../src/database/migrations/057_indexed_content_fields.js"; import { runMigrations, getMigrationStatus, @@ -77,6 +78,51 @@ describe("Database Migrations", () => { expect(status.applied).toHaveLength(MIGRATION_COUNT); // derived from MIGRATIONS map in runner.ts }); + it("should remove custom field indexes when rolling back indexed field support", async () => { + await runMigrations(db); + + const fieldId = "01J00000000000000000000000"; + const indexName = `idx_cf_${fieldId.toLowerCase()}`; + + await db + .insertInto("_emdash_collections") + .values({ id: "collection-1", slug: "posts", label: "Posts" }) + .execute(); + await db + .insertInto("_emdash_fields") + .values({ + id: fieldId, + collection_id: "collection-1", + slug: "priority", + label: "Priority", + type: "integer", + column_type: "INTEGER", + required: 0, + unique: 0, + default_value: null, + validation: null, + widget: null, + options: null, + sort_order: 0, + indexed: 1, + }) + .execute(); + await sql`CREATE INDEX ${sql.ref(indexName)} ON _emdash_fields (id)`.execute(db); + + await indexedContentFields.down(db); + + const indexes = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' AND name = ${indexName} + `.execute(db); + const fieldsTable = (await db.introspection.getTables()).find( + (table) => table.name === "_emdash_fields", + ); + + expect(indexes.rows).toEqual([]); + expect(fieldsTable?.columns.map((column) => column.name)).not.toContain("indexed"); + await expect(indexedContentFields.down(db)).resolves.not.toThrow(); + }); + it("should record migration in tracking table", async () => { await runMigrations(db); diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index da04c9e124..7b28da4363 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -429,6 +429,59 @@ describe("ContentRepository", () => { }); describe("orderBy", () => { + it("paginates indexed custom fields with stable null ordering", async () => { + await registry.createField("post", { + slug: "priority", + label: "Priority", + type: "number", + indexed: true, + }); + + const seeded = await repo.findMany("post"); + const priorities = [null, 2, 1, 2, null]; + for (const [index, item] of seeded.items.entries()) { + await repo.update("post", item.id, { data: { priority: priorities[index] } }); + } + + const collect = async (direction: "asc" | "desc") => { + const items = []; + let cursor: string | undefined; + do { + const page = await repo.findMany("post", { + limit: 2, + cursor, + orderBy: { field: "priority", direction }, + }); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor); + return items; + }; + + const ascending = await collect("asc"); + const descending = await collect("desc"); + const values = (items: typeof ascending) => items.map((item) => item.data.priority ?? null); + + expect(values(ascending)).toEqual([null, null, 1, 2, 2]); + expect(values(descending)).toEqual([2, 2, 1, null, null]); + expect(new Set(ascending.map((item) => item.id))).toHaveLength(5); + expect(new Set(descending.map((item) => item.id))).toHaveLength(5); + }); + + it("rejects unindexed custom order fields", async () => { + await registry.createField("post", { + slug: "priority", + label: "Priority", + type: "number", + }); + + await expect( + repo.findMany("post", { + orderBy: { field: "priority", direction: "asc" }, + }), + ).rejects.toThrow(EmDashValidationError); + }); + // Regression guard for "table headers aren't sort controls": the // admin now sends orderBy={field,direction} — the repo must accept // the columns the UI wants to expose, not just dates. diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index de6458f4ce..67f029f72f 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -143,6 +143,7 @@ describe("Database Migrations (Integration)", () => { "054_media_upload_attempts", "055_content_translation_group_locale_index", "056_collection_admin_config", + "057_indexed_content_fields", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index 0159b97425..cfc5a489d3 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -209,6 +209,49 @@ describe("SchemaRegistry", () => { expect(field.required).toBe(true); }); + it("keeps an indexed field's physical index in sync", async () => { + const listFieldIndexes = async () => + ( + await sql<{ name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND tbl_name = 'ec_posts' + AND name LIKE 'idx_cf_%' + `.execute(db) + ).rows; + + const field = await registry.createField("posts", { + slug: "priority", + label: "Priority", + type: "number", + indexed: true, + }); + + expect(field.indexed).toBe(true); + expect(await listFieldIndexes()).toHaveLength(1); + + await registry.updateField("posts", "priority", { indexed: false }); + expect(await listFieldIndexes()).toHaveLength(0); + + await registry.updateField("posts", "priority", { indexed: true }); + expect(await listFieldIndexes()).toHaveLength(1); + + await registry.deleteField("posts", "priority"); + expect(await listFieldIndexes()).toHaveLength(0); + }); + + it("rejects indexes for non-scalar fields", async () => { + await expect( + registry.createField("posts", { + slug: "body", + label: "Body", + type: "portableText", + indexed: true, + }), + ).rejects.toMatchObject({ code: "FIELD_NOT_INDEXABLE" }); + }); + it("should add column to content table when creating field", async () => { await registry.createField("posts", { slug: "title", From 76caef19fd782b6ced008b0ff5404bf234cc69bd Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:27:24 +0200 Subject: [PATCH 4/9] fix(content): remove redundant indexed cursor cast --- packages/core/src/database/repositories/content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index fadbd91a11..adee55790d 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -728,7 +728,7 @@ export class ContentRepository { } mappedResult.nextCursor = encodeIndexedFieldCursor( orderField, - lastOrderValue as IndexedOrderValue, + lastOrderValue, String(lastRow.id), ); } else { From 4edbf2de916678efd1c467a369a065cc8128a4c6 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:53:55 +0200 Subject: [PATCH 5/9] fix(content): use generated custom field indexes --- packages/core/src/schema/registry.ts | 3 +- .../database/repositories/content.test.ts | 46 +++++++++++++++- .../core/tests/unit/schema/registry.test.ts | 55 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index d97861d837..e0ddecd63d 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -1124,8 +1124,9 @@ export class SchemaRegistry { const indexName = this.getFieldIndexName(fieldId); await sql` - CREATE INDEX ${sql.ref(indexName)} + CREATE INDEX IF NOT EXISTS ${sql.ref(indexName)} ON ${sql.ref(tableName)} ( + deleted_at, (${sql.ref(columnName)} IS NOT NULL), ${sql.ref(columnName)}, id diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index 7b28da4363..104babb6f9 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -464,8 +464,50 @@ describe("ContentRepository", () => { expect(values(ascending)).toEqual([null, null, 1, 2, 2]); expect(values(descending)).toEqual([2, 2, 1, null, null]); - expect(new Set(ascending.map((item) => item.id))).toHaveLength(5); - expect(new Set(descending.map((item) => item.id))).toHaveLength(5); + expect(new Set(ascending.map((item) => item.id)).size).toBe(5); + expect(new Set(descending.map((item) => item.id)).size).toBe(5); + }); + + it("paginates indexed datetime fields without skipping equal values", async () => { + await registry.createField("post", { + slug: "starts_at", + label: "Starts at", + type: "datetime", + indexed: true, + }); + + const seeded = await repo.findMany("post"); + const startsAt = [ + null, + "2026-08-02T12:00:00.000Z", + "2026-07-01T08:30:00.000Z", + "2026-08-02T12:00:00.000Z", + null, + ]; + for (const [index, item] of seeded.items.entries()) { + await repo.update("post", item.id, { data: { starts_at: startsAt[index] } }); + } + + const items = []; + let cursor: string | undefined; + do { + const page = await repo.findMany("post", { + limit: 2, + cursor, + orderBy: { field: "starts_at", direction: "asc" }, + }); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor); + + expect(items.map((item) => item.data.starts_at ?? null)).toEqual([ + null, + null, + "2026-07-01T08:30:00.000Z", + "2026-08-02T12:00:00.000Z", + "2026-08-02T12:00:00.000Z", + ]); + expect(new Set(items.map((item) => item.id)).size).toBe(5); }); it("rejects unindexed custom order fields", async () => { diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index cfc5a489d3..e68b645898 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -241,6 +241,61 @@ describe("SchemaRegistry", () => { expect(await listFieldIndexes()).toHaveLength(0); }); + it("reuses an existing generated index when enabling indexed metadata", async () => { + const field = await registry.createField("posts", { + slug: "priority", + label: "Priority", + type: "number", + }); + const indexName = `idx_cf_${field.id.toLowerCase()}`; + + await sql` + CREATE INDEX ${sql.ref(indexName)} + ON ec_posts (deleted_at, (priority IS NOT NULL), priority, id) + WHERE deleted_at IS NULL + `.execute(db); + + await expect( + registry.updateField("posts", "priority", { indexed: true }), + ).resolves.toMatchObject({ indexed: true }); + + const indexes = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' AND name = ${indexName} + `.execute(db); + expect(indexes.rows).toHaveLength(1); + }); + + it("uses the generated index for indexed custom field ordering", async () => { + const field = await registry.createField("posts", { + slug: "priority", + label: "Priority", + type: "number", + indexed: true, + }); + const indexName = `idx_cf_${field.id.toLowerCase()}`; + + const ascending = await sql<{ detail: string }>` + EXPLAIN QUERY PLAN + SELECT * FROM ec_posts + WHERE deleted_at IS NULL + ORDER BY (priority IS NOT NULL) ASC, priority ASC, id ASC + LIMIT 51 + `.execute(db); + const descending = await sql<{ detail: string }>` + EXPLAIN QUERY PLAN + SELECT * FROM ec_posts + WHERE deleted_at IS NULL + ORDER BY (priority IS NOT NULL) DESC, priority DESC, id DESC + LIMIT 51 + `.execute(db); + + for (const plan of [ascending, descending]) { + const details = plan.rows.map((row) => row.detail).join("\n"); + expect(details).toContain(indexName); + expect(details).not.toContain("USE TEMP B-TREE"); + } + }); + it("rejects indexes for non-scalar fields", async () => { await expect( registry.createField("posts", { From cdb4a4590e1a9e2f8dd13f84de40a82b01116436 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:21:37 +0200 Subject: [PATCH 6/9] fix(content): keep indexed cursor scans ordered --- .../core/src/database/repositories/content.ts | 17 +--- .../repositories/content-order-plan.test.ts | 88 +++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 packages/core/tests/database/repositories/content-order-plan.test.ts diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index adee55790d..c7a22bdd5d 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -652,22 +652,13 @@ export class ContentRepository { `); } else if (safeOrderDirection === "ASC") { query = query.where(sql` - (${isPresent}) = ${trueLiteral} - AND ( - ${sql.ref(dbField)} > ${value} - OR (${sql.ref(dbField)} = ${value} AND ${sql.ref("id")} > ${cursorId}) - ) + (${isPresent}, ${sql.ref(dbField)}, ${sql.ref("id")}) + > (${trueLiteral}, ${value}, ${cursorId}) `); } else { query = query.where(sql` - (${isPresent}) < ${trueLiteral} - OR ( - (${isPresent}) = ${trueLiteral} - AND ( - ${sql.ref(dbField)} < ${value} - OR (${sql.ref(dbField)} = ${value} AND ${sql.ref("id")} < ${cursorId}) - ) - ) + (${isPresent}, ${sql.ref(dbField)}, ${sql.ref("id")}) + < (${trueLiteral}, ${value}, ${cursorId}) `); } } else { diff --git a/packages/core/tests/database/repositories/content-order-plan.test.ts b/packages/core/tests/database/repositories/content-order-plan.test.ts new file mode 100644 index 0000000000..621e62d886 --- /dev/null +++ b/packages/core/tests/database/repositories/content-order-plan.test.ts @@ -0,0 +1,88 @@ +import BetterSqlite3 from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import type { Database } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; + +const sqlite = new BetterSqlite3(":memory:"); +const captured: Array<{ sql: string; parameters: readonly unknown[] }> = []; +const db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") { + captured.push({ sql: event.query.sql, parameters: event.query.parameters }); + } + }, +}); +const repo = new ContentRepository(db); +let indexName: string; + +beforeAll(async () => { + await runMigrations(db); + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" }); + const field = await registry.createField("post", { + slug: "priority", + label: "Priority", + type: "number", + indexed: true, + }); + indexName = `idx_cf_${field.id.toLowerCase()}`; + + const insert = sqlite.prepare('INSERT INTO "ec_post" ("id", "priority") VALUES (?, ?)'); + sqlite.transaction(() => { + for (let i = 0; i < 1_100; i++) { + insert.run(`post-${i.toString().padStart(4, "0")}`, i % 20); + } + })(); + sqlite.exec("ANALYZE"); +}); + +afterAll(async () => { + await db.destroy(); +}); + +function getListPlan() { + const listQuery = captured.find( + (query) => query.sql.includes("select *") && query.sql.includes('from "ec_post"'), + ); + expect(listQuery, "expected to capture the content list query").toBeDefined(); + return sqlite + .prepare(`EXPLAIN QUERY PLAN ${listQuery!.sql}`) + .all(...listQuery!.parameters) as Array<{ detail: string }>; +} + +function expectIndexSearch(plan: Array<{ detail: string }>) { + const details = plan.map((row) => row.detail).join("\n"); + expect(details).toContain(`SEARCH ec_post USING INDEX ${indexName}`); + expect(details).not.toContain("SCAN ec_post"); + expect(details).not.toContain("TEMP B-TREE"); +} + +it("uses the custom-field index for ordered cursor pages", async () => { + captured.length = 0; + const firstPage = await repo.findMany("post", { + limit: 3, + orderBy: { field: "priority", direction: "asc" }, + }); + const firstPlan = getListPlan(); + + captured.length = 0; + const secondPage = await repo.findMany("post", { + limit: 3, + cursor: firstPage.nextCursor, + orderBy: { field: "priority", direction: "asc" }, + }); + const secondPlan = getListPlan(); + + expect(firstPage.items.map((item) => item.data.priority)).toEqual([0, 0, 0]); + expect(secondPage.items.map((item) => item.data.priority)).toEqual([0, 0, 0]); + expect(new Set([...firstPage.items, ...secondPage.items].map((item) => item.id)).size).toBe(6); + expect(firstPage.total).toBe(1_100); + expect(secondPage.total).toBe(1_100); + expectIndexSearch(firstPlan); + expectIndexSearch(secondPlan); +}); From 31ae8712066a33865136bf010dd3af36995b3d20 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:26:08 +0200 Subject: [PATCH 7/9] test(content): capture qualified list queries --- .../tests/database/repositories/content-order-plan.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/database/repositories/content-order-plan.test.ts b/packages/core/tests/database/repositories/content-order-plan.test.ts index 621e62d886..af8c75fd83 100644 --- a/packages/core/tests/database/repositories/content-order-plan.test.ts +++ b/packages/core/tests/database/repositories/content-order-plan.test.ts @@ -47,7 +47,10 @@ afterAll(async () => { function getListPlan() { const listQuery = captured.find( - (query) => query.sql.includes("select *") && query.sql.includes('from "ec_post"'), + (query) => + query.sql.includes('from "ec_post"') && + query.sql.includes("order by") && + query.sql.includes("limit"), ); expect(listQuery, "expected to capture the content list query").toBeDefined(); return sqlite From f28689cc1a95a16ecedaab868fcb849797eb69a1 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:54 +0200 Subject: [PATCH 8/9] fix(admin): clear the indexed flag for non-indexable field types --- packages/admin/src/components/FieldEditor.tsx | 7 +++++- .../core/tests/unit/schema/registry.test.ts | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index c34a94dd08..8fca2923ec 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -351,7 +351,12 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie required, unique, searchable: isSearchableType ? searchable : undefined, - indexed: isIndexableType ? indexed : undefined, + // `false`, not `undefined`: the server reads an absent `indexed` as + // "keep the stored value", so switching an indexed field to a type + // that cannot be indexed would keep the flag set and then fail its + // own validation. The Indexed switch is hidden for those types, so + // the editor could not clear it either. + indexed: isIndexableType ? indexed : false, validation: Object.keys(validation).length > 0 ? validation : null, }; diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index e68b645898..34da7bb5a4 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -241,6 +241,31 @@ describe("SchemaRegistry", () => { expect(await listFieldIndexes()).toHaveLength(0); }); + it("drops the index when an indexed field moves to a type that cannot carry one", async () => { + // The admin has to send `indexed: false` here. Omitting it means + // "keep the stored value", which would leave the flag set on a type + // that rejects it, and the update would fail its own validation with + // no way for the editor to clear the flag first. + await registry.createField("posts", { + slug: "summary", + label: "Summary", + type: "string", + indexed: true, + }); + + await expect( + registry.updateField("posts", "summary", { type: "text" }), + ).rejects.toMatchObject({ code: "FIELD_NOT_INDEXABLE" }); + + const updated = await registry.updateField("posts", "summary", { + type: "text", + indexed: false, + }); + + expect(updated.type).toBe("text"); + expect(updated.indexed).toBe(false); + }); + it("reuses an existing generated index when enabling indexed metadata", async () => { const field = await registry.createField("posts", { slug: "priority", From ddec8d2a12aa948c58355fb3246a98e963f423a1 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:45:03 +0200 Subject: [PATCH 9/9] test(admin): cover clearing the indexed flag on type change --- .../tests/components/FieldEditor.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx index b0654ebcda..7dac16e22a 100644 --- a/packages/admin/tests/components/FieldEditor.test.tsx +++ b/packages/admin/tests/components/FieldEditor.test.tsx @@ -342,6 +342,41 @@ describe("FieldEditor", () => { }); }); + describe("indexed flag", () => { + // The dialog overlay blocks Playwright's actionability check, so submit + // through the DOM node the way gallery-detail-panel.test.tsx does. + const save = async (screen: Awaited>) => { + const button = screen.getByRole("button", { name: "Update Field" }); + await expect.element(button).toBeEnabled(); + button.element().click(); + }; + + it("clears the flag when the field type cannot be indexed", async () => { + // The server reads an absent `indexed` as "keep the stored value", so + // sending `undefined` would leave the flag set on a type that cannot + // carry an index, and the update then fails its own validation. The + // Indexed switch is hidden for these types, so nothing else can clear it. + const onSave = vi.fn(); + const field = makeField({ slug: "body", label: "Body", type: "text", indexed: true }); + const screen = await render(); + + expect(screen.getByText("Indexed").query()).toBeNull(); + await save(screen); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ indexed: false })); + }); + + it("keeps the flag for a field type that can be indexed", async () => { + const onSave = vi.fn(); + const field = makeField({ slug: "priority", label: "Priority", indexed: true }); + const screen = await render(); + + await save(screen); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ indexed: true })); + }); + }); + describe("config step (file field)", () => { const fileField = makeField({ slug: "attachment",