feat(content): support indexed custom field sorting - #2212
Conversation
🦋 Changeset detectedLatest commit: e24c87f The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 920 lines across 35 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
a3f5bcc to
9509332
Compare
9509332 to
3a15f15
Compare
138f0cd to
33ed15e
Compare
There was a problem hiding this comment.
The server-side sorting approach is sound: it adds physical SQLite indexes on real ec_* columns for scalar field types, keeps pagination cursor-stable with explicit null ordering, and validates the indexed flag against a whitelist of indexable types. That fits EmDash’s schema-in-the-database architecture.
Two things block a clean sign-off:
-
AGENTS.md Discussion requirement. The PR introduces a new user-facing feature (indexed custom-field sorting) but the description explicitly states that the linked Discussion #1717 only covers
admin.listColumns(#2194) and “no Discussion has been opened for indexed sorting yet.” EmDash requires a maintainer-approved Discussion before merging a feature. This needs to be opened/approved before merge. -
FieldEditor bug when changing an indexed field to a non-indexable type. The editor sends
indexed: undefinedwhen the selected type is not indexable. The backend interpretsundefinedas “preserve existing value,” so an already-indexed field that is changed to a non-indexable type (e.g.string→text) fails withFIELD_NOT_INDEXABLEinstead of dropping the index. Fix by explicitly sendingfalsefor non-indexable types.
Other notes:
- The diff still includes the stacked #2194
admin.listColumnscommits/changeset. Review comments here focus on the indexed-sorting additions; the list-columns pieces should land via #2194. - Server support for custom-field sorting is complete, but the admin content list does not yet expose sort controls on custom columns (the new columns are display-only). If admin UI for selecting a custom sort is intended for this PR, it is missing.
- I did not run tests/lint/typecheck (no shell/tooling). Test coverage looks reasonable for null ordering, equal-value stability, unindexed-field rejection, and index lifecycle; the query-plan assertions are brittle but directly verify the stated performance goal.
| required, | ||
| unique, | ||
| searchable: isSearchableType ? searchable : undefined, | ||
| indexed: isIndexableType ? indexed : undefined, |
There was a problem hiding this comment.
[needs fixing] When the selected type is not indexable, this sends indexed: undefined. In updateField the backend computes nextIndexed = input.indexed ?? field.indexed, so an already-indexed field that is changed to a non-indexable type (e.g. string → text, same column type) keeps indexed = true and assertIndexableField throws FIELD_NOT_INDEXABLE. The UI gives the user no way to recover because the Indexed switch has disappeared.
Send false explicitly for non-indexable types so the index is dropped and the type change can succeed:
| indexed: isIndexableType ? indexed : undefined, | |
| indexed: isIndexableType ? indexed : false, |
| "@emdash-cms/admin": minor | ||
| --- | ||
|
|
||
| Adds opt-in database indexes for scalar custom fields and stable cursor pagination when ordering content lists by those fields. |
There was a problem hiding this comment.
[needs fixing] This changeset describes a new user-facing feature, but AGENTS.md requires a prior maintainer-approved Discussion for features. The PR description acknowledges that Discussion #1717 covers admin.listColumns (#2194) only and that no Discussion has been opened for indexed custom-field sorting yet. Please open and get approval for a Discussion covering this feature before merging.
8b50815 to
edded2b
Compare
There was a problem hiding this comment.
Re-review of the indexed custom-field sorting contribution.
Approach: The backend approach remains sound. Physical partial indexes on scalar field columns, validateIdentifier() before any dynamic identifier reaches SQL, and an explicit (col IS NOT NULL, col, id) cursor tuple keep null ordering stable across pages. The change slots cleanly into the existing orderBy contract and ContentRepository.findMany, and the locale filter is preserved on the content-table query. Admin list columns from the stacked #2194 changes remain display-only and correctly bypass the sort controls.
What changed since the last pass:
- The
FieldEditorbug is fixed — it now sendsindexed: falsewhen the selected type is not indexable, and the regression tests verify that. - The reviewer-facing comments flagged in the previous review have been removed.
Headline process blocker (still unchanged): Per CONTRIBUTING.md/AGENTS.md, feature PRs require a maintainer-approved Discussion. The PR description still lists Discussion #2397 as “opened, awaiting maintainer review”, and the corresponding checklist item is unchecked, so that requirement is not satisfied.
Features | Open a Discussion and wait for maintainer approval.
Feature PRs without prior maintainer approval will be closed.
The code is otherwise ready once the Discussion is approved.
New code-side finding: seed/validate.ts now validates admin.listColumns but does not validate the new indexed flag against the field type. A seed that declares e.g. indexed: true on a portableText or repeater field passes static validation but will fail at apply-time with FIELD_NOT_INDEXABLE. The validator should catch this before apply.
What I checked:
- SQL safety: dynamic identifiers go through
sql.ref()on validated field/collection slugs;content.tsre-validates the slug from_emdash_fieldsbefore using it as an identifier. - Locale filtering:
findManystill applieswhere.locale, and ordering happens per-locale row. - Index lifecycle:
SchemaRegistrycreates/dropsidx_cf_*consistently on field create, update, and delete; dropping a collection drops the table and its indexes implicitly. - Cursor encoding/decoding for indexed fields rejects malformed payloads and null encodings, and the null-stable pagination tests cover both SQLite and Postgres.
- Lingui wrapping for new admin strings (
Indexed) and RTL-safe classes (text-start). - Changesets present and written as user-facing release notes.
- Tests cover null-stable pagination, query-plan index use, index lifecycle, and the
FieldEditorflag clearing. - No new queries are added to logged-out routes; the additional
_emdash_fieldslookup only occurs on authenticated content list requests with a customorderBy.
Findings
-
[needs fixing]
packages/core/src/seed/validate.ts:181The validator now checks
admin.listColumns, but it does not validate the newindexedflag against the field type. A seed that declaresindexed: trueon a non-scalar type such asportableTextorrepeaterwill pass validation yet fail at apply-time whenSchemaRegistrythrowsFIELD_NOT_INDEXABLE. The static validator should reject this up front.Import
isIndexableFieldTypeand add a check after the type-validity branch:import { FIELD_TYPES, isIndexableFieldType, MAX_COLLECTION_LIST_COLUMNS } from "../schema/types.js";} else if (!(FIELD_TYPES as readonly string[]).includes(field.type)) { errors.push(`${fieldPrefix}.type: unsupported field type "${field.type}"`); } else if (field.indexed && !isIndexableFieldType(field.type as FieldType)) { errors.push( `${fieldPrefix}.indexed: type "${field.type}" cannot be indexed`, ); }
Three blocks defend sending `indexed: false` or narrate a Playwright workaround. The tests already name the contract they cover. The bind-budget note above the seed batch size stays: it records a limit a reader would otherwise raise.
The file moved to 061 when upstream took 059; this import still named 060, so the suite could not load the module at all.
8334d80 to
e24c87f
Compare
What does this PR do?
Adds server-backed sorting for custom scalar fields that are explicitly marked
indexed.Collection fields gain an opt-in
indexedflag. EmDash validates that flag against supported scalar field types, maintains a normalized index table when content changes, and allows the existingorderBycontract to sort by those field slugs. Sorting remains database-backed and cursor-safe, so it applies to the complete result set instead of only the currently loaded admin page.This enables uses such as SEO score, ticket priority, workflow rank, or other numeric and textual metadata without introducing full-table scans.
This PR is stacked on #2194 to keep the five related content-list contributions conflict-free and reviewable at the same time. #2194 should merge first; GitHub will then narrow this PR to its unique sorting commit automatically.
Addresses the structured sorting portion of #2179.
Discussion: #2397 (indexed sorting); #1717 (
admin.listColumns)Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runadmin.listColumns(feat(admin): show configured fields in content lists #2194).AI-generated code disclosure
Screenshots / test output
Validated on EmDash 0.32.0 (base
776d65f7) with Node 24.16.0 and pnpm 11.9.0 on macOS. The workspace has all six stacked contributions applied, so these are the integrated totals rather than a per-PR subset:CI runs each PR on its own branch.