Skip to content

feat: reference field type (storage-less edges + admin UI)#1928

Open
MA2153 wants to merge 33 commits into
emdash-cms:mainfrom
MA2153:feat/reference-field-admin-ui
Open

feat: reference field type (storage-less edges + admin UI)#1928
MA2153 wants to merge 33 commits into
emdash-cms:mainfrom
MA2153:feat/reference-field-admin-ui

Conversation

@MA2153

@MA2153 MA2153 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a reference field type end-to-end — a storage-less field that links entries via content-reference edges rather than a column on the collection's table.

Core (emdash)

  • Reference fields are storage-less: the schema registry skips column DDL for them, and their values live as edges in _emdash_content_references instead of an ec_* column. Existing reference columns keep their data but are no longer written.
  • Selections ride in the content create/update body under a references key and are written atomically with the entry in a single transaction; the content GET hydrates them alongside SEO and bylines.
  • Each resolved reference carries a display title (from the entry's title, then name), so backlinks and picked entries show a readable label rather than a slug.
  • A reference field's config (relation, target collection, multiple) flows through the admin manifest; its backing relation definition is created and removed together with the field, and the relation link is immutable across schema updates.
  • Seed files apply a reference field's $ref: value as an edge (seed shape unchanged).
  • Wires the previously-unregistered relation and reference-edge API routes into injectCoreRoutes.

Admin (@emdash-cms/admin)

  • Configure a reference field in the schema editor (target collection, single/multiple).
  • Pick and reorder referenced entries in the entry editor, saved with the entry in one request.
  • Read-only "Referenced by" backlinks panel on referenced entries.

Closes #

Type of change

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Screenshots / test output

emdash   Test Files  352 passed | 1 skipped (353)
              Tests  4672 passed | 3 skipped (4675)

admin    Test Files   87 passed (87)
              Tests  1065 passed (1065)

🤖 Generated with Claude Code

Remains to be fixed

  • Backlinks show a buffer in the sidebar even when they are not present
  • Clicking "Add a reference" and not selecting anything, then clicking again, shows no items
  • The way backlinks are listed should match the new sidebar aesthetics

Remains to be verified

  • The search functionality in the content selector uses FTS, same mechanism as the admin's collection page
  • The content selector functionality is an abstract and reusable component

MA2153 and others added 19 commits July 10, 2026 11:42
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`handleContentCreate`/`handleContentUpdate` already accept a `references`
body key, but the zod schemas didn't list it, so `parseBody` silently
stripped it before it reached the handler.
Creating a reference field now creates its backing relation def
transactionally, updating the field's label PATCHes the relation's
childLabel, and deleting the field deletes the relation and its edges.
The admin no longer has to orchestrate these multi-step writes itself.

Also fixes withTransaction to short-circuit when already inside a
transaction (db.isTransaction), rather than attempting an illegal
nested .transaction() call — required for the field-create/update/
delete handlers to nest their relation writes with SchemaRegistry's
own internally-transacted field writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Type STORAGELESS_FIELD_TYPES as ReadonlySet<string> so membership checks
against DB-sourced field types need no cast, resolving the lint diagnostic
introduced with stripStoragelessDataKeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reference fields are storage-less, so a seed defining one now creates the
backing relation (like the schema handler) and writes a $ref value in the
field's data as a content-reference edge instead of a table column. Previously
applySeed threw "no such column" for any seed using a reference field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- resolveEntries now attaches a display `title` (entry `title`, then `name`,
  else null) to every EntryRef, so picked entries and backlinks show a
  readable label instead of a slug; hydrated through content GET and the
  admin editor/backlinks sidebar.
- Wire the relation and reference-edge API routes into injectCoreRoutes;
  they existed but were never registered, so /_emdash/api/relations 404'd
  and the "Referenced by" panel silently hid itself.
- Preserve `targetCollection` and `multiple` on reference field validation
  so the create handler no longer rejects the field for a missing target.
- Backlinks sidebar resolves relations by translation_group, not name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/core/src/api/handlers/content.ts
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 79da749

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

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Minor
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/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 3,014 lines across 34 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/needs-review No maintainer or bot review yet label Jul 10, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 79da749

@MA2153 MA2153 added the bot:review Trigger an emdashbot code review on this PR label Jul 10, 2026
# Conflicts:
#	packages/admin/src/components/ContentEditor.tsx
#	packages/admin/src/router.tsx
@MA2153
MA2153 marked this pull request as draft July 10, 2026 20:40
@github-actions github-actions Bot removed the review/needs-review No maintainer or bot review yet label Jul 10, 2026
@MA2153 MA2153 added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 21, 2026
@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 Jul 21, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 21, 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.

I re-reviewed PR #1928 (feat/reference-field-admin-ui) against the diff in /repo/.flue-pr.diff and the project's AGENTS.md conventions.

Headline: 3 previously flagged issues remain unaddressed, and I found 2 new ones. None of them are purely cosmetic: one corrupts seeded collection schemas, one breaks the picker locale behavior, one allows user fields to shadow a runtime-hydrated property, one leaves a misleading test docstring, and two comments violate the repo's comment discipline rules.

Previously flagged (still unfixed):

  1. ContentPickerModal accepts a locale prop but never forwards it to useInfiniteQuery's queryKey or fetchContentList, so the server-side locale filtering added in this PR is bypassed.
  2. RESERVED_FIELD_SLUGS does not reserve references, letting a user-defined field shadow the runtime-hydrated references payload on content items.
  3. The manifest-reference test docstring still describes the old behavior (reference validation not copied), while the test now asserts the opposite.

New findings:
4. SchemaRegistry.createContentTable omits the STORAGELESS_FIELD_TYPES guard that createField already uses. Seeded reference collections therefore get orphan ec_* TEXT columns, while admin-created reference collections do not.
5. Two comments reference temporary task IDs (Task 6, Task 7), violating AGENTS.md: "Never reference issues, PRs, or review threads in comments" and "Never number comments."

Verdict: request_changes — the storage-less DDL inconsistency and locale/query mismatch are concrete regressions, the reserved-slug gap is a data-integrity hazard, and the AGENTS.md comment violations are explicit convention blockers per the review skill criteria.


Findings

  • [needs fixing] packages/admin/src/components/ContentPickerModal.tsx:125

    The modal receives a locale prop (line 38) and uses it for client-side translation-group collapsing, but neither the queryKey nor fetchContentList includes locale. The PR adds locale filtering on the server, so this query effectively bypasses it, returning every locale and then relying on client-side heuristics to hide the wrong-locale rows.

    Add locale to the query key and forward it to the API call:

    		queryKey: ["content-picker", activeCollection, locale, trimmedSearch],
    		queryFn: ({ pageParam }) =>
    			fetchContentList(activeCollection, {
    				limit: 50,
    				cursor: pageParam,
    				locale,
    				search: trimmedSearch || undefined,
    			}),
    
  • [needs fixing] packages/core/src/schema/types.ts:334

    references is a runtime-hydrated property on ContentItem (edges are merged from _emdash_content_references), but it is not listed in RESERVED_FIELD_SLUGS. A user-defined field named references would shadow that runtime property and break the admin editor/reference save path.

    	"terms",
    	"bylines",
    	"byline",
    	"references",
    ];
    
    
  • [needs fixing] packages/core/tests/integration/manifest-reference.test.ts:3

    The header comment says _buildManifest copies validation except for reference fields, but the test below asserts that reference fields do receive relation, targetCollection, multiple, etc. This stale docstring actively contradicts the code and will mislead future maintainers. Rewrite it to describe the current behavior.

    /**
     * `_buildManifest` copies reference-field config (`relation`, `targetCollection`,
     * `multiple`) into the manifest descriptor so the admin editor receives
     * `kind: "reference"` and can drive the reference picker widget.
     */
    
  • [needs fixing] packages/core/src/schema/registry.ts:953

    createContentTable adds a column for every field, including reference. createField already guards against this (relations.ts line 616) using STORAGELESS_FIELD_TYPES, so admin-created reference fields avoid a column. But seeded collections created via createSeedCollection pass fields to createContentTable, which produces orphan ec_* TEXT columns for reference fields.

    Add the same guard at the top of the loop:

    		for (const field of fields) {
    			if (STORAGELESS_FIELD_TYPES.has(field.type)) continue;
    
    			const columnName = this.getColumnName(field.slug);
    			const columnType = COLUMN_TYPE_TO_DATA_TYPE[FIELD_TYPE_TO_COLUMN[field.type]];
    			table = table.addColumn(columnName, columnType, (column) => {
    
  • [needs fixing] packages/core/src/api/handlers/relations.ts:376

    The JSDoc contains see Task 6. AGENTS.md comments rules state: "Never reference issues, PRs, or review threads in comments" and "Never number comments." Task numbers are temporary review/tracking artifacts and become stale the moment the change merges.

     * the content create/update transaction can reuse the same resolution logic.
     * `db` accepts a `Kysely<Database>` or a `Transaction<Database>`
    
  • [needs fixing] packages/admin/src/components/ContentEditor.tsx:349

    This comment contains Task 7, which violates AGENTS.md: comments must not reference temporary tasks/issues and must not be numbered. Rewrite the line to explain the behavior on its own.

    	// Seeded from the hydrated first page; the picker fills titles for
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026
@MA2153
MA2153 marked this pull request as draft July 21, 2026 17:37
@github-actions github-actions Bot removed the review/needs-rereview Author pushed changes since the last review label Jul 21, 2026
@MA2153

MA2153 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in 8b1ff0c9.

  • Fixed createContentTable so seeded reference fields obey STORAGELESS_FIELD_TYPES and do not create orphan columns. Added a real-schema regression test covering both a stored field and a storage-less reference field.
  • Updated the manifest reference test docstring to describe the current behavior.
  • Removed both temporary task references from source comments.

I verified the other two findings and am not applying them:

  • The picker query intentionally remains locale-independent. Reference edges identify translation groups, so server-side locale filtering would hide valid targets whose group exists only in another locale. The modal collapses the complete result to one row per translation group, prefers the active locale, falls back deterministically, and recomputes when locale changes. The cached query data itself is locale-invariant.
  • references is not a field collision. User-defined field values remain at item.data.references; hydrated reference edges are a distinct top-level item.references property consumed by referenceState. Reserving the slug would reject existing schemas without preventing shadowing or data loss.

Verification after integrating the remote branch:

  • pnpm exec vitest run tests/fields/reference.test.ts tests/integration/manifest-reference.test.ts (9 passed)
  • pnpm typecheck
  • pnpm lint:json | jq .diagnostics | length (0)

@MA2153
MA2153 marked this pull request as ready for review July 22, 2026 23:17
@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label Jul 22, 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 PR #1928 (feat/reference-field-admin-ui).

Approach assessment: The feature is architecturally sound for EmDash. Storing reference values as edges in _emdash_content_references rather than ec_* columns matches the storage-less field model, and wiring the create/update references key into the existing content transaction keeps writes atomic. The admin picker and read-only backlinks panel fit the current editor patterns, and seeding $ref: values through to edges preserves seed shape. This is the right change.

Status vs. the previous review: Four of the six previously flagged issues have been addressed:

  • createContentTable now skips STORAGELESS_FIELD_TYPES (seeded reference fields no longer get orphan columns).
  • The manifest-reference test docstring now describes the current behavior.
  • The Task 6 and Task 7 comments have been removed.

Two concrete issues remain unaddressed and are reposted below:

  1. ContentPickerModal still accepts a locale prop but never forwards it to the query key or fetchContentList, so the server-side locale filter is bypassed.
  2. RESERVED_FIELD_SLUGS still does not reserve references, so a user-defined field can shadow the runtime-hydrated references payload.

Both are needs_fixing: the first is a correctness/regression in picker behavior, and the second is a data-integrity hazard that can break the reference save/hydration path. The PR is otherwise close to mergeable; once these two gaps are closed the review would be an approve.

I also note the PR description’s own "Remains to be fixed" list (backlinks buffer, empty picker after cancel, aesthetics) — those are acknowledged follow-ups and are not counted as new review findings.


Findings

  • [needs fixing] packages/admin/src/components/ContentPickerModal.tsx:125

    The modal receives locale (line 64) and uses it for client-side translation-group collapsing, but neither the queryKey nor the fetchContentList call includes locale. The content-list handler supports server-side locale filtering (handleContentList reads params.locale), so this query fetches every locale and then relies on heuristics to hide wrong-locale rows. That bypasses the intended filtering and can show, then hide, entries from other locales.

    Add locale to the query key and forward it to the API:

    	const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    		queryKey: ["content-picker", activeCollection, locale, trimmedSearch],
    		queryFn: ({ pageParam }) =>
    			fetchContentList(activeCollection, {
    				limit: 50,
    				cursor: pageParam,
    				locale,
    				search: trimmedSearch || undefined,
    			}),
    
  • [needs fixing] packages/core/src/schema/types.ts:317-334

    references is a runtime-hydrated property on ContentItem (handleContentGet/hydrateReferences merges edge data into item.references). It is not listed in RESERVED_FIELD_SLUGS, so a user-defined field named references can shadow that runtime property and break the admin editor/reference save path. A field slug matching a runtime-hydrated key must be rejected at creation time, just like terms, bylines, and byline.

    export const RESERVED_FIELD_SLUGS = [
    	"id",
    	"slug",
    	"status",
    	"author_id",
    	"primary_byline_id",
    	"created_at",
    	"updated_at",
    	"published_at",
    	"scheduled_at",
    	"deleted_at",
    	"version",
    	"live_revision_id",
    	"draft_revision_id",
    	// Runtime-hydrated fields
    	"terms",
    	"bylines",
    	"byline",
    	"references",
    ];
    

@MA2153

MA2153 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

The bot keeps parroting the same points. Please ignore its review.

@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 Jul 23, 2026
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.

4 participants