Skip to content

fix(core): enforce required and multiple on reference fields - #2503

Closed
MA2153 wants to merge 3 commits into
emdash-cms:mainfrom
MA2153:feat/reference-required-multiple
Closed

fix(core): enforce required and multiple on reference fields#2503
MA2153 wants to merge 3 commits into
emdash-cms:mainfrom
MA2153:feat/reference-required-multiple

Conversation

@MA2153

@MA2153 MA2153 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stacked on #2496. Base branches can't live on a fork, so this PR targets main and its diff currently includes #2492's and #2496's commits. Review 8efd08fd on its own — that's the whole of this change.

Closes the second of the two P1 findings on #1928 (r3757946596): a reference field's multiple and required settings were UI-only, so the content and edge APIs would both happily write several children to a multiple: false field, or clear a required one.

Where the checks live. Both go in setReferenceChildren — the one function every edge write passes through, whether it came from the content body, the standalone PUT .../children endpoint, or the seed engine. Enforcing in the content handler alone would have left the constraint bypassable by calling the endpoint directly, and enforcing in both would have left two copies to drift apart.

The one check that can't live there is a create whose payload omits a required reference field altogether: no edge write is attempted for a field nobody mentioned, so there is nothing for the choke point to see. handleContentCreate checks that separately, before opening the transaction.

Updates keep partial semantics. A reference field an update doesn't mention is left alone, exactly as a required column-backed field is — only an explicit [] is a violation.

A relation with no reference field behind it stays unconstrained. Relations exist independently of fields — the relations API can create one directly, and a plugin may rely on that — so there is no field config to enforce and nothing is rejected. That also means a typo'd relation group still fails, but as NOT_FOUND from relation resolution, which is the accurate error.

A note on required self-references

Writing the tests surfaced this: a required reference field pointing at its own collection makes that collection's first entry impossible to create — there is nothing to reference yet. That's inherent to required on a self-relation, the same way a NOT NULL self-FK behaves, and this PR doesn't try to special-case it. Worth knowing before the admin exposes the toggle; the schema editor may want to warn.

Part of #386.

Stack

  1. feat(core): make reference fields storage-less #2492 — storage-less field types + relation lifecycle + seed
  2. feat(core): write and read reference edges with the content entry #2496 — reference edges written and read with the content entry
  3. This PR — server-side required / multiple enforcement
  4. Public content query API hydration
  5. MCP content tools
  6. OpenAPI documentation for the reference surface
  7. Admin UI + browser e2e

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 — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

$ pnpm exec vitest run          # packages/core
 Test Files  467 passed | 2 skipped (469)
      Tests  5802 passed | 9 skipped (5811)

$ pnpm typecheck                # all packages — clean
$ pnpm lint:json | jq '.diagnostics | length'
0

tests/integration/content/reference-constraints.test.ts, both dialects, 9 cases:

  • more than one child on a single-reference field is rejected on create — and the entry is not persisted, asserted by row count, so the check can't be one that fires after the write;
  • exactly one child is accepted;
  • an empty list for a required field is rejected, as is a create that omits it (asserting the message names the field);
  • an update clearing a required field is rejected, while an update that doesn't mention it succeeds;
  • both constraints hold when the edge endpoint is called directly — the bypass this PR exists to close;
  • a relation with no backing field accepts two children, pinning the deliberate carve-out above.

MA2153 and others added 3 commits August 16, 2026 16:42
A reference field's selections are edges in _emdash_content_references,
not a column on the collection's table. The registry skips column DDL for
storage-less field types, the schema handlers own the backing relation's
lifecycle (created with the field, destroyed with it, target collection
immutable), and the previously unregistered relation and reference-edge
routes are wired into injectCoreRoutes.

A storage-less field never appears in `data` in either direction: it is
excluded from the generated Zod shape (so a required reference field is
satisfiable at all), rejected with a VALIDATION_ERROR when a caller sends
one, and filtered out of reads so a column left behind by an older version
cannot round-trip back into a save. That replaces the reference-target
existence pass in validateContentData, which validated a column-backed
value that no longer exists.

Seeds apply a reference field's $ref: value as an edge, so seed files keep
working unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reference selections ride in the content create/update body under a
`references` key and are written in the same transaction as the entry, so
a child that fails to resolve aborts the whole save rather than leaving an
entry with taxonomies, bylines and SEO already committed. The editor GET
opts into hydrating the first page of each reference field's children.

Two paths that used to lose edges now carry them: duplicating an entry
copies its outgoing references onto the copy, and purging a row clears the
group's edges only once no sibling — trashed ones included, they are still
restorable — is left to own them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reference field's `multiple: false` and `required` settings were UI-only:
the content body and the edge endpoint would both happily write several
children to a single-reference field, or clear a required one.

Both checks now live in `setReferenceChildren`, the one function every edge
write passes through, so the content body, the standalone endpoint and the
seed engine can't drift from each other. The create path additionally
rejects a payload that omits a required reference field altogether, which
no edge write would otherwise visit. Updates keep partial semantics: a
field the payload doesn't mention is left alone.

A relation with no reference field behind it stays unconstrained — the
relations API can create one directly, and it carries no field config to
enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8efd08f

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/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/admin Minor
@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

@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's core change is the right shape: centralize required/multiple enforcement in setReferenceChildren and add an up-front validateRequiredReferencesPresent check in handleContentCreate for omitted required fields. The tests cover the important paths (create, update, direct edge endpoint, and the unconstrained-relation carve-out).

The diff is large because it currently contains the stacked #2492/#2496 commits; I reviewed the constraint-enforcement surface (and the adjacent code it touches) for correctness.

Two issues:

  1. Translation creates are over-validated. handleContentCreate runs the omitted-required check unconditionally, including when translationOf is set. Since reference edges are owned by the translation_group, a translation of an entry that already satisfies a required reference inherits those edges and should not be forced to repeat them. As written, a translation create that omits references is rejected even though the group already has the required edge.

  2. Type/runtime mismatch for hydrated references. ContentItem["references"]["children"] is missing translationGroup, but resolveEntries populates it and the API schema exposes it.

Neither is a security or data-loss issue, so comment rather than request_changes.


// Selections that are present get checked as they're written; this is the
// required field the payload leaves out altogether.
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);

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] handleContentCreate rejects creates that omit a required reference field before opening the transaction. That is correct for a fresh entry, but it also applies when body.translationOf is set. Translations share the source's translation_group, so reference edges are already satisfied by the source row; requiring the caller to re-send them breaks translation creation for collections with required references.

Skip the omitted-ref check when creating a translation (explicit body.references still flows through setReferenceChildren and is validated there):

Suggested change
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);
// Selections that are present get checked as they're written; this is the
// required field the payload leaves out altogether. Translations share
// their source's translation_group (and therefore its reference edges),
// so they inherit any required references already satisfied there.
if (!body.translationOf) {
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);
if (!requiredRefs.success) return requiredRefs;
}

Comment on lines +322 to +329
{
children: Array<{
id: string;
slug: string | null;
collection: string;
title: string | null;
locale: string | null;
sortOrder?: number;

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] resolveEntries returns EntryRef objects containing translationGroup, and the API schema adds it to entryRefSchema, but this database-layer type for hydrated references omits it. Add the field so callers don't get a TypeScript error when reading the value that is present at runtime:

Suggested change
{
children: Array<{
id: string;
slug: string | null;
collection: string;
title: string | null;
locale: string | null;
sortOrder?: number;
children: Array<{
id: string;
slug: string | null;
collection: string;
title: string | null;
locale: string | null;
translationGroup: string | null;
sortOrder?: number;
}>;

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 3,346 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.

@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label Aug 16, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 8efd08f

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core overlap review/awaiting-author Reviewed; waiting on the author to respond size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant