Skip to content

feat(content): filter indexed custom fields#2213

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

feat(content): filter indexed custom fields#2213
logelog wants to merge 6 commits into
emdash-cms:mainfrom
logelog:feat/indexed-custom-field-filtering

Conversation

@logelog

@logelog logelog commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds typed, server-backed filtering for custom fields that are explicitly marked indexed.

The content list REST API, core client, runtime, and plugin content access contract accept AND-combined fieldFilters. Supported conditions include exact scalar matches, missing values, membership through in, and inclusive or exclusive numeric or textual ranges. Field names and values are schema-validated, bounded, and resolved only against indexed fields.

This lets plugins and admin experiences filter complete result sets by metadata such as ticket state, priority, workflow status, or SEO score without browser-side filtering or full-table scans.

This PR is stacked on #2212, which supplies the indexed-field storage and validation contract. Both PRs can be reviewed at the same time, but #2212 should merge first; GitHub will then narrow this PR to its unique filtering commit automatically.

Addresses the structured filtering portion of #2179.
Discussion: #1717

Type of change

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

Checklist

Screenshots / test output

Validated on EmDash 0.31 with Node 24 in WSL2:

  • focused core filtering, API, client, repository, and runtime tests: 212 passed
  • focused admin content-list browser tests: 69 passed
  • combined five-PR integration suite: 234 core and 120 admin tests passed
  • pnpm typecheck
  • pnpm lint:quick
  • pnpm format:check
  • pnpm --filter emdash --filter @emdash-cms/admin build

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: abd8982

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 1,474 lines across 47 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@2213

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: abd8982

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

This PR adds a coherent, architecture-aligned implementation of indexed custom-field filtering: it stores filterable fields as real columns, validates identifiers before they reach SQL, parameterizes values, and adds coverage across repository, handler, client, plugin, and MCP surfaces. The sorting/index plumbing and admin list-column work are related but distinct concerns, so the main judgement call is whether the PR should be split or retitled.

What I checked:

  • SQL safety: dynamic column refs use sql.ref(...) after validateIdentifier, filter values are parameterized, in lists are built with sql.join, and the partial expression index design is correct. No injection vectors found.
  • Authorization/logged-out paths: fieldFilters flows through handleContentList and plugin content:read, both authenticated; public loader filters via the pre-existing loader path are untouched. No new anonymous hot-path queries.
  • Locale filtering: content-table list queries already filter by locale when supplied; indexed field values are per-row, so this stays correct.
  • Index discipline: createFieldIndex matches the deleted_at IS NULL filter used by list/count queries.
  • API envelope: responses remain { items, total, nextCursor? }.

Blocking issues found: none. Issues to fix before merge:

  1. A new repository test asserts new Set(...).toHaveLength(5), which will fail because Sets have size, not length.
  2. The new admin list-column formatter emits hard-coded "—", "✓", and ", ", and formats dates with the browser default locale instead of Lingui — violating the AGENTS.md localization rule.

Lower-priority suggestions:

  • The PR title says "filtering" but the diff ships three separate changesets/features (filtering, sorting/indexes, list columns). Consider splitting or updating the title/description per scope discipline.
  • Seed validation checks admin.listColumns shape but not that the slugs are declared fields in the collection.
  • createFieldIndex could be idempotent with IF NOT EXISTS to survive partial creates or re-runs.

Overall direction is solid; the test and localization items should be addressed.


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);

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 assertion compares new Set(...).toHaveLength(5). Set instances expose .size, not .length, so toHaveLength will see undefined and fail. The same pattern is repeated on the next line for descending.

Suggested change
expect(new Set(ascending.map((item) => item.id))).toHaveLength(5);
expect(new Set(ascending.map((item) => item.id)).size).toBe(5);
expect(new Set(descending.map((item) => item.id)).size).toBe(5);

Comment on lines +1116 to +1117
function formatListColumnValue(column: ContentListColumn, value: unknown): string {
if (value === null || value === undefined || value === "") return "—";

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] formatListColumnValue hard-codes user-facing rendering: "—" for empty/unknown values (lines 1117, 1121, 1142, 1155), "✓" for true booleans (line 1145), a literal ", " separator for multi-select (line 1142), and date.toLocaleDateString() which uses the browser locale rather than the active Lingui catalog. AGENTS.md requires all admin UI strings to go through Lingui and admin layout to be RTL-safe; a plain em-dash/checkmark may also render oddly in RTL contexts.

formatListColumnValue is currently a module-level helper without access to the Lingui t function. Make ContentListCustomCell a proper component that calls useLingui(), then pass t (or MessageDescriptors) through to the formatter. For example:

const emptyLabel = msg`—`;
const trueLabel = msg`✓`;

and inside the component:

const text = formatListColumnValue(t, column, value);

The multi-select join and date formatting should also use Lingui/catalog-aware helpers rather than hard-coded ", " and toLocaleDateString().

fields[field.slug] = entry;
}

const configuredListColumns = collection.admin?.listColumns ?? [];

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.

[suggestion] The PR title and main changeset are about indexed custom-field filtering, but this hunk and the related schema/admin code introduce a third concern: collection-configured admin list columns (and the diff also ships a separate sorting/index feature). Per AGENTS.md scope discipline, unrelated/semi-related features should not ride along on a single PR. Consider either splitting this into focused PRs or updating the title/description/changeset set so reviewers can evaluate each feature on its own.

errors.push(`${prefix}: label is required`);
}

if (collection.admin !== 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.

[suggestion] Seed validation checks that admin.listColumns entries are syntactically valid field slugs, but it does not verify that each slug corresponds to a field declared in the same collection. The runtime silently drops unknown/unsupported slugs with console.warn, so a typo in a seed file would pass validation but be ignored at runtime. Keep seed validation in sync with runtime rules by checking slugs against the collection's field list here.

const columnName = this.getColumnName(fieldSlug);
const indexName = this.getFieldIndexName(fieldId);

await sql`

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.

[suggestion] createFieldIndex runs CREATE INDEX without IF NOT EXISTS. If the generated idx_cf_<fieldId> index already exists — from a partial migration, a retried transaction, or a manual DBA change — this throws instead of being a no-op. Adding IF NOT EXISTS makes the runtime tolerant of re-runs and matches the defensive style used elsewhere (e.g., DROP INDEX IF EXISTS in the down migration).

@logelog
logelog force-pushed the feat/indexed-custom-field-filtering branch from 80b9da5 to abd8982 Compare July 22, 2026 23:39
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Jul 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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.

1 participant