Skip to content

feat(content): support indexed custom field sorting - #2212

Open
logelog wants to merge 16 commits into
emdash-cms:mainfrom
logelog:feat/indexed-custom-field-sorting
Open

feat(content): support indexed custom field sorting#2212
logelog wants to merge 16 commits into
emdash-cms:mainfrom
logelog:feat/indexed-custom-field-sorting

Conversation

@logelog

@logelog logelog commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds server-backed sorting for custom scalar fields that are explicitly marked indexed.

Collection fields gain an opt-in indexed flag. EmDash validates that flag against supported scalar field types, maintains a normalized index table when content changes, and allows the existing orderBy contract 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI generated code: Claude Opus 5, GPT-5.6 (via private dev orchestra)

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:

  • core: 403 files, 5211 tests passed, 3 skipped
  • admin: 117 files, 1421 tests passed
  • Cloudflare: 21 files, 300 tests passed
  • Workerd: 12 files, 84 tests passed
  • real local D1: 2 files, 7 tests passed
  • typechecks and builds for admin, core, cloudflare, workerd, and the demo lab

CI runs each PR on its own branch.

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e24c87f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2212

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2212

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2212

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2212

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2212

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2212

emdash

npm i https://pkg.pr.new/emdash@2212

create-emdash

npm i https://pkg.pr.new/create-emdash@2212

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2212

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2212

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2212

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2212

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2212

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2212

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2212

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2212

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2212

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2212

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2212

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2212

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2212

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2212

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2212

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2212

commit: e24c87f

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.

  2. FieldEditor bug when changing an indexed field to a non-indexable type. The editor sends indexed: undefined when the selected type is not indexable. The backend interprets undefined as “preserve existing value,” so an already-indexed field that is changed to a non-indexable type (e.g. stringtext) fails with FIELD_NOT_INDEXABLE instead of dropping the index. Fix by explicitly sending false for non-indexable types.

Other notes:

  • The diff still includes the stacked #2194 admin.listColumns commits/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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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. stringtext, 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:

Suggested change
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 3, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Aug 3, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Aug 10, 2026
@logelog
logelog force-pushed the feat/indexed-custom-field-sorting branch from 8b50815 to edded2b Compare August 10, 2026 17:30
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 10, 2026
@logelog logelog closed this Aug 10, 2026
@logelog logelog reopened this Aug 10, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 FieldEditor bug is fixed — it now sends indexed: false when 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.ts re-validates the slug from _emdash_fields before using it as an identifier.
  • Locale filtering: findMany still applies where.locale, and ordering happens per-locale row.
  • Index lifecycle: SchemaRegistry creates/drops idx_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 FieldEditor flag clearing.
  • No new queries are added to logged-out routes; the additional _emdash_fields lookup only occurs on authenticated content list requests with a custom orderBy.

Findings

  • [needs fixing] packages/core/src/seed/validate.ts:181

    The validator now checks admin.listColumns, but it does not validate the new indexed flag against the field type. A seed that declares indexed: true on a non-scalar type such as portableText or repeater will pass validation yet fail at apply-time when SchemaRegistry throws FIELD_NOT_INDEXABLE. The static validator should reject this up front.

    Import isIndexableFieldType and 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`,
    						);
    					}
    

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond review/needs-rereview Author pushed changes since the last review and removed review/needs-rereview Author pushed changes since the last review review/awaiting-author Reviewed; waiting on the author to respond labels Aug 10, 2026
@logelog
logelog force-pushed the feat/indexed-custom-field-sorting branch from 8334d80 to e24c87f Compare August 11, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants