Skip to content

Commit 92ca6fd

Browse files
committed
feat(content): support indexed custom field sorting
1 parent 8edcd0d commit 92ca6fd

23 files changed

Lines changed: 519 additions & 35 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": minor
4+
---
5+
6+
Adds opt-in database indexes for scalar custom fields and stable cursor pagination when ordering content lists by those fields.

docs/src/content/docs/concepts/collections.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ Every field supports these properties:
216216
| `type` | `FieldType` | One of the 16 field types |
217217
| `required` | `boolean` | Whether the field must have a value |
218218
| `unique` | `boolean` | Whether values must be unique across entries |
219+
| `indexed` | `boolean` | Allow efficient sorting by this field |
219220
| `defaultValue` | `unknown` | Default value for new entries |
220221
| `validation` | `object` | Type-specific validation rules |
221222
| `widget` | `string` | Custom widget identifier |
@@ -228,6 +229,15 @@ Every field supports these properties:
228229
`version`, `live_revision_id`, `draft_revision_id`, `terms`, `bylines`, `byline`.
229230
</Aside>
230231

232+
Set `indexed: true` when a collection query needs to use a custom field in `orderBy`. Indexes are
233+
supported for `string`, `url`, `number`, `integer`, `boolean`, `datetime`, `select`, `reference`,
234+
and `slug` fields. EmDash rejects indexed JSON, rich content, and other non-scalar field types.
235+
236+
<Aside type="caution">
237+
Indexes improve ordered reads but use additional storage and add work to content writes. Only index
238+
fields that are used for sorting.
239+
</Aside>
240+
231241
## Validation rules
232242

233243
The `validation` object varies by field type. Its full shape is:

docs/src/content/docs/reference/field-types.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,12 +386,18 @@ All fields support these common properties:
386386
| `type` | `FieldType` | Field type (required) |
387387
| `required` | `boolean` | Require a value (default: false) |
388388
| `unique` | `boolean` | Enforce uniqueness (default: false) |
389+
| `indexed` | `boolean` | Enable indexed custom-field sorting |
389390
| `defaultValue` | `unknown` | Default value for new entries |
390391
| `validation` | `object` | Type-specific validation rules |
391392
| `widget` | `string` | Custom widget override |
392393
| `options` | `object` | Widget configuration |
393394
| `sortOrder` | `number` | Display order in admin |
394395

396+
`indexed` is available for scalar fields: `string`, `url`, `number`, `integer`, `boolean`,
397+
`datetime`, `select`, `reference`, and `slug`. An indexed field can be passed as the `orderBy`
398+
field in content list queries. Avoid indexing fields that are not used for sorting because every
399+
index adds storage and write overhead.
400+
395401
## Reserved field slugs
396402

397403
These slugs are reserved and cannot be used:

docs/src/content/docs/reference/mcp-server.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ Add a new field to a collection's schema. This adds a column to the database tab
351351
| `validation` | `object` | No | Constraints: `min`, `max`, `minLength`, `maxLength`, `pattern`, `options` |
352352
| `options` | `object` | No | Widget config: `collection` (for references), `rows` (for textarea) |
353353
| `searchable` | `boolean` | No | Include in full-text search index |
354+
| `indexed` | `boolean` | No | Enable indexed sorting by this field |
354355
| `translatable` | `boolean` | No | Whether this field is translatable (default true) |
355356

356357
Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, `reference`, `json`, `slug`.

docs/src/content/docs/themes/seed-files.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Each collection definition creates a content type in the database:
132132
| `type` | `string` | Yes | Field type |
133133
| `required` | `boolean` | No | Validation: field must have a value |
134134
| `unique` | `boolean` | No | Validation: value must be unique |
135+
| `indexed` | `boolean` | No | Enable indexed sorting by this field |
135136
| `defaultValue` | `any` | No | Default value for new entries |
136137
| `validation` | `object` | No | Additional validation rules |
137138
| `widget` | `string` | No | Admin UI widget override |

packages/admin/src/components/FieldEditor.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ import { AllowedTypesEditor } from "./AllowedTypesEditor";
3232

3333
const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g;
3434
const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g;
35+
const SEARCHABLE_FIELD_TYPES = new Set<FieldType>([
36+
"string",
37+
"text",
38+
"portableText",
39+
"slug",
40+
"url",
41+
]);
42+
const INDEXABLE_FIELD_TYPES = new Set<FieldType>([
43+
"string",
44+
"url",
45+
"number",
46+
"integer",
47+
"boolean",
48+
"datetime",
49+
"select",
50+
"reference",
51+
"slug",
52+
]);
53+
54+
function isSearchableFieldType(type: FieldType | null): type is FieldType {
55+
return type !== null && SEARCHABLE_FIELD_TYPES.has(type);
56+
}
57+
58+
function isIndexableFieldType(type: FieldType | null): type is FieldType {
59+
return type !== null && INDEXABLE_FIELD_TYPES.has(type);
60+
}
3561

3662
// ============================================================================
3763
// Types
@@ -67,6 +93,7 @@ interface FieldFormState {
6793
required: boolean;
6894
unique: boolean;
6995
searchable: boolean;
96+
indexed: boolean;
7097
minLength: string;
7198
maxLength: string;
7299
min: string;
@@ -89,6 +116,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
89116
required: field.required,
90117
unique: field.unique,
91118
searchable: field.searchable,
119+
indexed: field.indexed ?? false,
92120
minLength: field.validation?.minLength?.toString() ?? "",
93121
maxLength: field.validation?.maxLength?.toString() ?? "",
94122
min: field.validation?.min?.toString() ?? "",
@@ -111,6 +139,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState {
111139
required: false,
112140
unique: false,
113141
searchable: false,
142+
indexed: false,
114143
minLength: "",
115144
maxLength: "",
116145
min: "",
@@ -138,7 +167,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
138167
}
139168
}, [open, field]);
140169

141-
const { step, selectedType, slug, label, required, unique, searchable } = formState;
170+
const { step, selectedType, slug, label, required, unique, searchable, indexed } = formState;
142171
const { minLength, maxLength, min, max, pattern, options } = formState;
143172
const setField = <K extends keyof FieldFormState>(key: K, value: FieldFormState[K]) =>
144173
setFormState((prev) => ({ ...prev, [key]: value }));
@@ -312,12 +341,8 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
312341
}
313342

314343
// Only include searchable for text-based fields
315-
const isSearchableType =
316-
selectedType === "string" ||
317-
selectedType === "text" ||
318-
selectedType === "portableText" ||
319-
selectedType === "slug" ||
320-
selectedType === "url";
344+
const isSearchableType = isSearchableFieldType(selectedType);
345+
const isIndexableType = isIndexableFieldType(selectedType);
321346

322347
const input: CreateFieldInput = {
323348
slug,
@@ -326,6 +351,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
326351
required,
327352
unique,
328353
searchable: isSearchableType ? searchable : undefined,
354+
indexed: isIndexableType ? indexed : undefined,
329355
validation: Object.keys(validation).length > 0 ? validation : null,
330356
};
331357

@@ -441,17 +467,20 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
441467
onCheckedChange={(checked) => setField("unique", checked)}
442468
label={<span className="text-sm">{t`Unique`}</span>}
443469
/>
444-
{(selectedType === "string" ||
445-
selectedType === "text" ||
446-
selectedType === "portableText" ||
447-
selectedType === "slug" ||
448-
selectedType === "url") && (
470+
{isSearchableFieldType(selectedType) && (
449471
<Switch
450472
checked={searchable}
451473
onCheckedChange={(checked) => setField("searchable", checked)}
452474
label={<span className="text-sm">{t`Searchable`}</span>}
453475
/>
454476
)}
477+
{isIndexableFieldType(selectedType) && (
478+
<Switch
479+
checked={indexed}
480+
onCheckedChange={(checked) => setField("indexed", checked)}
481+
label={<span className="text-sm">{t`Indexed`}</span>}
482+
/>
483+
)}
455484
</div>
456485

457486
{/* Type-specific validation */}

packages/admin/src/lib/api/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface SchemaField {
5959
required: boolean;
6060
unique: boolean;
6161
searchable: boolean;
62+
indexed: boolean;
6263
defaultValue?: unknown;
6364
validation?: {
6465
min?: number;
@@ -113,6 +114,7 @@ export interface CreateFieldInput {
113114
required?: boolean;
114115
unique?: boolean;
115116
searchable?: boolean;
117+
indexed?: boolean;
116118
defaultValue?: unknown;
117119
validation?: {
118120
min?: number;
@@ -132,6 +134,7 @@ export interface UpdateFieldInput {
132134
required?: boolean;
133135
unique?: boolean;
134136
searchable?: boolean;
137+
indexed?: boolean;
135138
defaultValue?: unknown;
136139
validation?: {
137140
min?: number;

packages/admin/tests/components/ContentTypeEditor.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
4242
required: false,
4343
unique: false,
4444
searchable: false,
45+
indexed: false,
4546
sortOrder: 0,
4647
createdAt: "2025-01-01T00:00:00Z",
4748
...overrides,

packages/admin/tests/components/FieldEditor.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function makeField(overrides: Partial<SchemaField> = {}): SchemaField {
4242
required: true,
4343
unique: false,
4444
searchable: true,
45+
indexed: false,
4546
sortOrder: 0,
4647
createdAt: new Date().toISOString(),
4748
...overrides,
@@ -127,6 +128,7 @@ describe("FieldEditor", () => {
127128
it("shows searchable checkbox for string type", async () => {
128129
const screen = await render(<FieldEditor {...defaultProps} field={stringField} />);
129130
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
131+
await expect.element(screen.getByText("Indexed")).toBeInTheDocument();
130132
});
131133

132134
it("shows min/max length validation for string type", async () => {
@@ -162,6 +164,7 @@ describe("FieldEditor", () => {
162164
const screen = await render(<FieldEditor {...defaultProps} field={numberField} />);
163165
await expect.element(screen.getByLabelText("Min Value")).toBeInTheDocument();
164166
await expect.element(screen.getByLabelText("Max Value")).toBeInTheDocument();
167+
await expect.element(screen.getByText("Indexed")).toBeInTheDocument();
165168
});
166169

167170
it("does not show searchable for number type", async () => {
@@ -201,6 +204,7 @@ describe("FieldEditor", () => {
201204
it("shows searchable checkbox for text type", async () => {
202205
const screen = await render(<FieldEditor {...defaultProps} field={textField} />);
203206
await expect.element(screen.getByText("Searchable")).toBeInTheDocument();
207+
expect(screen.getByText("Indexed").query()).toBeNull();
204208
});
205209
});
206210

packages/core/src/api/schemas/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export const createFieldBody = z
126126
options: fieldWidgetOptions,
127127
sortOrder: z.number().int().min(0).optional(),
128128
searchable: z.boolean().optional(),
129+
indexed: z.boolean().optional(),
129130
translatable: z.boolean().optional(),
130131
})
131132
.meta({ id: "CreateFieldBody" });
@@ -142,6 +143,7 @@ export const updateFieldBody = z
142143
options: fieldWidgetOptions,
143144
sortOrder: z.number().int().min(0).optional(),
144145
searchable: z.boolean().optional(),
146+
indexed: z.boolean().optional(),
145147
translatable: z.boolean().optional(),
146148
})
147149
.meta({ id: "UpdateFieldBody" });
@@ -208,6 +210,7 @@ export const fieldSchema = z
208210
options: z.record(z.string(), z.unknown()).nullable(),
209211
sortOrder: z.number().int(),
210212
searchable: z.boolean(),
213+
indexed: z.boolean(),
211214
translatable: z.boolean(),
212215
createdAt: z.string(),
213216
updatedAt: z.string(),

0 commit comments

Comments
 (0)