Skip to content

feat(content): filter indexed custom fields - #2213

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

feat(content): filter indexed custom fields#2213
logelog wants to merge 19 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

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: d64fc47

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: d64fc47

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

Comment thread packages/core/tests/database/repositories/content.test.ts Outdated
Comment thread packages/admin/src/components/ContentList.tsx Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This PR sits at the top of a four-PR stack, so its diff necessarily contains 2194 and 2212. Each capability is reviewed in its own PR; the stack order is in the description. Leaving this thread open since it is a judgement call rather than something the code can resolve.

Comment thread packages/core/src/seed/validate.ts
Comment thread packages/core/src/schema/registry.ts
@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

@logelog
logelog force-pushed the feat/indexed-custom-field-filtering branch from 51754bf to 754981b Compare August 7, 2026 21:53
@logelog
logelog force-pushed the feat/indexed-custom-field-filtering branch from 754981b to 3c4bbf5 Compare August 7, 2026 23:48
@logelog logelog closed this Aug 8, 2026
@logelog logelog reopened this Aug 8, 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.

This re-review focuses on the new indexed custom-field filtering path in PR #2213. The approach is sound and fits EmDash’s architecture: filters are validated in the REST query schema, resolved against _emdash_fields with the indexed flag, applied as parameterized SQL through sql.ref()/Kysely fragments, and flow through the existing authenticated content:read route and plugin content:read capability. Test coverage for scalar, in, range, null, budget, injection, cursor pagination, and query-plan index usage is comprehensive.

What I checked:

  • SQL safety: dynamic column identifiers are validated with validateIdentifier and emitted via sql.ref(...); values, IN lists, and cursor tuple members are parameterized.
  • Authorization: fieldFilters is only accepted by authenticated handlers (content:read route, plugin content:read) and cannot override the subscriber status=published guard.
  • Locale filtering: field filters apply per row; locale filtering in findMany is unchanged, so translated indexed fields are still filtered within the requested locale.
  • Cursor pagination: null/ present tuple ordering and row-value cursor comparisons line up with the (isPresent, field, id) index key.
  • API envelope: responses still return { items, total, nextCursor? }.
  • Logged-out hot path: no new anonymous-route queries are introduced by the filtering feature.
  • AGENTS.md conventions: code is formatted with tabs, changesets are present, and no reviewer-facing comments were added.

One code regression remains: resolveFieldFilters resolves field metadata before confirming the collection exists, so a request to a non-existent collection that includes fieldFilters returns a misleading VALIDATION_ERROR instead of the COLLECTION_NOT_FOUND returned for the same request without filters. This breaks the handler’s existing error contract and should be aligned (for example, by checking _emdash_collections existence when metadata is empty, or by validating the collection before resolving filters).

One non-code process item is also still open: the author has explicitly noted that the Discussion linked in the PR description covers admin.listColumns rather than indexed filtering, so the maintainer-approved Discussion requirement for this feature is still pending. That is outside the code review but affects whether the feature should merge.

The prior review’s two concrete issues (test Set.size assertions and localized admin custom-cell formatting) are resolved.

Comment on lines +1923 to +1938

const rows = 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", "in", fields)
.where("field.indexed", "=", 1)
.select(["field.slug", "field.type"])
.execute();
const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType]));

const normalized = fields.map((field) => {
const fieldType = metadata.get(field);
if (!fieldType || !isIndexableFieldType(fieldType)) {
throw new EmDashValidationError(
`Cannot filter by field "${field}". Custom fields must be indexed before filtering.`,

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] resolveFieldFilters fetches field metadata from _emdash_collections/_emdash_fields before the backing ec_* table is touched. If the collection does not exist, the join returns no rows and the loop throws Cannot filter by field "...". Custom fields must be indexed before filtering. The handleContentList catch block maps that EmDashValidationError to VALIDATION_ERROR, but the same request without fieldFilters (or any content-list request to a real collection) would hit the missing ec_* table and return COLLECTION_NOT_FOUND. That makes the error code depend on whether filters are supplied, which is a contract regression.

Check collection existence when field metadata is missing, and surface the same COLLECTION_NOT_FOUND error the rest of the handler uses. For example:

Suggested change
const rows = 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", "in", fields)
.where("field.indexed", "=", 1)
.select(["field.slug", "field.type"])
.execute();
const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType]));
const normalized = fields.map((field) => {
const fieldType = metadata.get(field);
if (!fieldType || !isIndexableFieldType(fieldType)) {
throw new EmDashValidationError(
`Cannot filter by field "${field}". Custom fields must be indexed before filtering.`,
const normalized = fields.map(async (field) => {
const fieldType = metadata.get(field);
if (!fieldType || !isIndexableFieldType(fieldType)) {
const collection = await this.db
.selectFrom("_emdash_collections")
.where("slug", "=", type)
.select("id")
.executeTakeFirst();
if (!collection) {
throw new CollectionNotFoundError(type);
}
throw new EmDashValidationError(
`Cannot filter by field "${field}". Custom fields must be indexed before filtering.`,
);
}
return this.normalizeFieldFilter(field, fieldType, resolvedFilters[field]);
});

This needs a matching CollectionNotFoundError (or reuse of the existing missing-table mapping) and a handler catch that converts it to COLLECTION_NOT_FOUND. Please also add a test covering a list request with fieldFilters against a missing collection.

logelog added 2 commits August 8, 2026 04:14
`findMany` races the page query against the count query. A collection whose
table is missing rejects both, and `Promise.all` returns on the first while
the other stays in flight holding a pooled connection. A Postgres pool
destroyed inside that window never finishes closing.

This predates the branch. The missing-collection regression test added here
is the first to reach the path on Postgres, so the suite cannot pass without
it.
@logelog logelog closed this Aug 8, 2026
@logelog logelog reopened this Aug 8, 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.

The indexed custom-field filtering approach remains sound and fits EmDash’s architecture: filters are validated in the REST query schema, resolved against _emdash_fields with indexed = 1, applied as parameterized SQL through sql.ref() / raw boolean expressions, and only exposed on authenticated content-list paths (REST, plugin content:read, and internal handlers). Cursor pagination, null ordering, and index-backed query plans line up with the (isPresent, field, id) index key, and tests cover scalar, in, range, null, budget, injection, cursor stability, and query-plan index usage.

This re-review confirms that the prior review’s concrete code regression is fixed: resolveFieldFilters now checks collection existence when metadata is empty, so requests to non-existent collections return COLLECTION_NOT_FOUND instead of a misleading VALIDATION_ERROR. The two resolved prior items (test Set.size assertions and localized admin custom-cell formatting) also look correct.

New code issues found:

  1. Residual error-contract edge case. If a request to a non-existent collection includes fieldFilters with an invalid field identifier, the identifier validation in resolveFieldFilters still throws before the collection-existence fallback, so the handler returns VALIDATION_ERROR instead of matching the no-filters COLLECTION_NOT_FOUND. The fix is to move identifier validation after the collection-existence fallback.
  2. AGENTS.md comment-discipline violations. Several newly-added comments justify decisions, narrate rejected alternatives, or address the reviewer rather than explaining a non-obvious invariant. These should be deleted.

The maintainer-approved Discussion requirement for indexed filtering (distinct from the existing Discussion for admin.listColumns) remains pending as a non-code process item.

Comment on lines +1924 to +1932
}
for (const field of fields) {
try {
validateIdentifier(field, "content filter field");
} catch {
throw new EmDashValidationError(`Invalid content filter field: ${field}`);
}
}

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] Identifier validation runs before the collection-existence fallback. For a request to a non-existent collection that also includes an invalid field name (e.g. {"x y": ...}), this throws EmDashValidationError before the fallback can detect the missing collection, so the handler returns VALIDATION_ERROR instead of the COLLECTION_NOT_FOUND returned for the same request without filters. That is a residual break in the existing error contract.

Move the identifier validation loop after the metadata / collection-existence check so missing collections are detected first:

Suggested change
}
for (const field of fields) {
try {
validateIdentifier(field, "content filter field");
} catch {
throw new EmDashValidationError(`Invalid content filter field: ${field}`);
}
}
const rows = 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", "in", fields)
.where("field.indexed", "=", 1)
.select(["field.slug", "field.type"])
.execute();
const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType]));
if (metadata.size === 0) {
const collection = await this.db
.selectFrom("_emdash_collections")
.where("slug", "=", type)
.select("id")
.executeTakeFirst();
if (!collection) return [];
}
for (const field of fields) {
try {
validateIdentifier(field, "content filter field");
} catch {
throw new EmDashValidationError(`Invalid content filter field: ${field}`);
}
}

Comment on lines +1943 to +1947
// A missing collection produces the same empty result as an unindexed
// field, and reporting it as a filter problem would hide the real cause:
// the same request without `fieldFilters` answers COLLECTION_NOT_FOUND.
// Returning no filters lets the query reach its missing backing table,
// which is where that error is raised.

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 block is a reviewer-facing justification for the fallback. AGENTS.md prohibits comments that justify decisions or narrate rejected alternatives. The test below (reports a missing collection the same way with and without field filters) already documents the contract. Delete the comment.

Suggested change
// A missing collection produces the same empty result as an unindexed
// field, and reporting it as a filter problem would hide the real cause:
// the same request without `fieldFilters` answers COLLECTION_NOT_FOUND.
// Returning no filters lets the query reach its missing backing table,
// which is where that error is raised.
const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType]));
if (metadata.size === 0) {
const collection = await this.db
.selectFrom("_emdash_collections")
.where("slug", "=", type)
.select("id")
.executeTakeFirst();
if (!collection) return [];
}

Comment on lines +354 to +358
// `false`, not `undefined`: the server reads an absent `indexed` as
// "keep the stored value", so switching an indexed field to a type
// that cannot be indexed would keep the flag set and then fail its
// own validation. The Indexed switch is hidden for those types, so
// the editor could not clear it either.

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 comment justifies why the editor sends false rather than undefined. AGENTS.md treats justification comments as violations. The code indexed: isIndexableType ? indexed : false is clear enough; delete the comment.

Suggested change
// `false`, not `undefined`: the server reads an absent `indexed` as
// "keep the stored value", so switching an indexed field to a type
// that cannot be indexed would keep the flag set and then fail its
// own validation. The Indexed switch is hidden for those types, so
// the editor could not clear it either.
searchable: isSearchableType ? searchable : undefined,
indexed: isIndexableType ? indexed : false,
validation: Object.keys(validation).length > 0 ? validation : null,

Comment on lines +112 to +114
// The filters resolve against `_emdash_fields`, which exists, so an
// unknown collection would otherwise surface as a filter problem and
// hide the real cause.

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] The test name already explains what is being asserted (reports a missing collection the same way with and without field filters). The inline comment restates the rationale and is reviewer-facing narration. Delete it.

Suggested change
// The filters resolve against `_emdash_fields`, which exists, so an
// unknown collection would otherwise surface as a filter problem and
// hide the real cause.
it("reports a missing collection the same way with and without field filters", async () => {
const withFilters = await handleContentList(ctx.db, "ghosts", {
fieldFilters: { priority: "urgent" },
});

Comment on lines +245 to +248
// The admin has to send `indexed: false` here. Omitting it means
// "keep the stored value", which would leave the flag set on a type
// that rejects it, and the update would fail its own validation with
// no way for the editor to clear the flag first.

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 comment narrates the setup rationale and a rejected alternative (Omitting it means ...). The test name already describes the behavior (drops the index when an indexed field moves to a type that cannot carry one). Delete the comment.

Suggested change
// The admin has to send `indexed: false` here. Omitting it means
// "keep the stored value", which would leave the flag set on a type
// that rejects it, and the update would fail its own validation with
// no way for the editor to clear the flag first.
it("drops the index when an indexed field moves to a type that cannot carry one", async () => {
await registry.createField("posts", {
slug: "summary",
label: "Summary",
type: "string",
indexed: true,
});

Comment on lines +346 to +347
// The dialog overlay blocks Playwright's actionability check, so submit
// through the DOM node the way gallery-detail-panel.test.tsx does.

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 comment narrates a workaround, references another test file, and explains a rejected approach. AGENTS.md discourages narrative comments that address the reviewer. The helper name save is self-explanatory; delete the comment.

Suggested change
// The dialog overlay blocks Playwright's actionability check, so submit
// through the DOM node the way gallery-detail-panel.test.tsx does.
describe("indexed flag", () => {
const save = async (screen: Awaited<ReturnType<typeof render>>) => {
const button = screen.getByRole("button", { name: "Update Field" });
await expect.element(button).toBeEnabled();
button.element().click();
};

logelog added 2 commits August 8, 2026 07:54
Identifier validation ran first, so a request naming both a collection that
does not exist and an invalid filter field answered VALIDATION_ERROR while
the same request without filters answered COLLECTION_NOT_FOUND. The field
names reach the lookup as bound parameters, so deferring validation past the
collection check keeps the query parameterized.

Drop four comments that justify decisions rather than record an invariant.
Three more comments narrate a workaround, name another test file, or defend
an omission. The remaining two record a footgun a reader would otherwise trip
over: a stranded pool connection, and the bind-parameter budget behind a
batch size.
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