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/.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/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 5647a57398..4fa4c193f6 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -42,6 +42,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"; @@ -63,6 +70,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; @@ -157,6 +166,7 @@ export function ContentList({ collection, collectionLabel, items, + listColumns = [], trashedItems = [], isLoading, isTrashedLoading, @@ -315,7 +325,7 @@ export function ContentList({ } })(); }; - const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + const colSpan = (i18n ? 5 : 4) + listColumns.length + (bulkEnabled ? 1 : 0); return (
@@ -504,6 +514,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; @@ -955,6 +976,7 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + listColumns, selectable, selected, onToggleSelect, @@ -984,6 +1006,9 @@ function ContentListItem({ {title} + {listColumns.map((column) => ( + + ))} + + {text} + + + ); +} + +function formatListColumnValue(column: ContentListColumn, value: unknown): string { + if (value === null || value === undefined || value === "") return "—"; + + const optionLabel = (optionValue: unknown): string => { + const text = scalarListColumnValue(optionValue); + if (text === undefined) return "—"; + 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 ? values.map(optionLabel).join(", ") : "—"; + } + case "boolean": + return value === true || value === 1 || value === "1" || value === "true" ? "✓" : "—"; + case "datetime": { + const text = scalarListColumnValue(value); + if (text === undefined) return "—"; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : date.toLocaleDateString(); + } + case "number": + case "string": + default: + return scalarListColumnValue(value) ?? "—"; + } +} + +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/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/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..f050d846ad 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; @@ -54,6 +59,7 @@ export interface SchemaField { required: boolean; unique: boolean; searchable: boolean; + indexed: boolean; defaultValue?: unknown; validation?: { min?: number; @@ -80,6 +86,7 @@ export interface CreateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: string[]; urlPattern?: string; hasSeo?: boolean; @@ -90,6 +97,7 @@ export interface UpdateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: string[]; urlPattern?: string; hasSeo?: boolean; @@ -106,6 +114,7 @@ export interface CreateFieldInput { required?: boolean; unique?: boolean; searchable?: boolean; + indexed?: boolean; defaultValue?: unknown; validation?: { min?: number; @@ -125,6 +134,7 @@ export interface UpdateFieldInput { required?: boolean; unique?: boolean; searchable?: boolean; + indexed?: boolean; defaultValue?: unknown; validation?: { min?: number; diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 4b8fb9530f..897d813ca3 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -571,6 +571,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({ @@ -585,6 +598,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 67e569d589..6825e981c0 100644 --- a/packages/admin/tests/components/ContentList.test.tsx +++ b/packages/admin/tests/components/ContentList.test.tsx @@ -111,6 +111,47 @@ 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("✓")).toBeInTheDocument(); + }); }); describe("empty states", () => { 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 f2922289e8..176228f71f 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(), @@ -118,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" }); @@ -134,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" }); @@ -175,6 +185,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(), @@ -199,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/astro/types.ts b/packages/core/src/astro/types.ts index 042313ece0..409b3219c2 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..d7ceecab74 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( @@ -324,6 +325,7 @@ async function exportCollections(db: Kysely): Promise): 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/055_indexed_content_fields.ts b/packages/core/src/database/migrations/055_indexed_content_fields.ts new file mode 100644 index 0000000000..998185c85d --- /dev/null +++ b/packages/core/src/database/migrations/055_indexed_content_fields.ts @@ -0,0 +1,36 @@ +import { sql, type Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +const FIELD_ID_PATTERN = /^[0-9A-Z]{26}$/; + +/** Mark custom fields whose structured list queries are backed by a physical index. */ +export async function up(db: Kysely): 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 a3ce955d45..c1a6ea92e1 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -56,6 +56,8 @@ import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; import * as m052 from "./052_media_usage_read_index.js"; import * as m053 from "./053_plugin_mcp_tools.js"; +import * as m054 from "./054_collection_admin_config.js"; +import * as m055 from "./055_indexed_content_fields.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -110,6 +112,8 @@ const MIGRATIONS: Readonly> = Object.freeze({ "051_content_taxonomies_denorm": m051, "052_media_usage_read_index": m052, "053_plugin_mcp_tools": m053, + "054_collection_admin_config": m054, + "055_indexed_content_fields": m055, }); /** 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 351a49538c..319d3cd3d9 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -19,6 +19,7 @@ import type { } from "./types.js"; import { EmDashValidationError, + InvalidCursorError, ScheduledNotDueError, encodeCursor, decodeCursor, @@ -30,6 +31,51 @@ 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; +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 @@ -501,7 +547,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"; @@ -533,26 +580,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") @@ -573,11 +662,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, + String(lastRow.id), + ); + } else { + const orderStr = + typeof lastOrderValue === "string" || typeof lastOrderValue === "number" + ? String(lastOrderValue) + : ""; + mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id)); + } } return mappedResult; @@ -1702,4 +1806,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 0f4ba13ed7..9cc3025f3f 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -291,6 +291,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: { enabled: boolean, weights: Record } @@ -331,6 +332,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/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5e4a247255..de4114cfab 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -225,6 +225,17 @@ const FIELD_TYPE_TO_KIND: Record = { repeater: "repeater", }; +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 */ @@ -2356,12 +2367,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/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 a2608f45a2..8ac9918b92 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, @@ -25,6 +26,7 @@ import { type CollectionWithFields, type FieldType, FIELD_TYPE_TO_COLUMN, + isIndexableFieldType, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS, } from "./types.js"; @@ -35,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"]); @@ -69,10 +72,19 @@ const VALID_COLLECTION_SUPPORTS: ReadonlySet = new Set { + 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 +281,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, @@ -300,6 +329,7 @@ export class SchemaRegistry { const fieldSlugs = 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"); } @@ -335,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, }; }); @@ -351,6 +382,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, @@ -362,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(); } @@ -408,6 +446,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), @@ -571,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 @@ -603,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(); @@ -620,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") @@ -702,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) => { @@ -722,6 +775,7 @@ export class SchemaRegistry { : field.searchable ? 1 : 0, + indexed: nextIndexed ? 1 : 0, translatable: input.translatable !== undefined ? input.translatable @@ -749,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") @@ -856,6 +918,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); }); @@ -1025,6 +1091,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 */ @@ -1224,6 +1324,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, @@ -1259,6 +1360,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 6d5055e981..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 */ @@ -155,6 +175,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 +191,7 @@ export interface Collection { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports: CollectionSupport[]; source?: CollectionSource; /** Whether this collection has SEO metadata fields enabled */ @@ -201,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; @@ -215,6 +244,7 @@ export interface CreateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: CollectionSupport[]; source?: CollectionSource; urlPattern?: string; @@ -230,6 +260,7 @@ export interface UpdateCollectionInput { labelSingular?: string; description?: string; icon?: string; + admin?: CollectionAdminConfig; supports?: CollectionSupport[]; urlPattern?: string; hasSeo?: boolean; @@ -255,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; } @@ -283,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 6c1da34284..cbc461c523 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, @@ -193,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, @@ -207,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, @@ -231,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, @@ -245,6 +249,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..3bf574fb1a 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 */ @@ -89,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/src/seed/validate.ts b/packages/core/src/seed/validate.ts index d4396d6c14..6401f11581 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -108,6 +108,23 @@ export function validateSeed(data: unknown): ValidationResult { errors.push(`${prefix}: label is required`); } + 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`); + } + } + } + } + } + // 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..d8355003e5 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/055_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); @@ -136,6 +182,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/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 6767b491d5..5491b01963 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -140,6 +140,8 @@ describe("Database Migrations (Integration)", () => { "051_content_taxonomies_denorm", "052_media_usage_read_index", "053_plugin_mcp_tools", + "054_collection_admin_config", + "055_indexed_content_fields", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index ee9e379005..0afe3ee451 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,65 @@ 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", + "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..cfc5a489d3 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, @@ -196,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",