feat: reference field type (storage-less edges + admin UI)#1928
feat: reference field type (storage-less edges + admin UI)#1928MA2153 wants to merge 33 commits into
Conversation
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 detectedLatest commit: 79da749 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
# Conflicts: # packages/admin/src/components/ContentEditor.tsx # packages/admin/src/router.tsx
There was a problem hiding this comment.
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):
ContentPickerModalaccepts alocaleprop but never forwards it touseInfiniteQuery'squeryKeyorfetchContentList, so the server-side locale filtering added in this PR is bypassed.RESERVED_FIELD_SLUGSdoes not reservereferences, letting a user-defined field shadow the runtime-hydratedreferencespayload on content items.- 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:125The modal receives a
localeprop (line 38) and uses it for client-side translation-group collapsing, but neither thequeryKeynorfetchContentListincludeslocale. The PR addslocalefiltering 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
localeto 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:334referencesis a runtime-hydrated property onContentItem(edges are merged from_emdash_content_references), but it is not listed inRESERVED_FIELD_SLUGS. A user-defined field namedreferenceswould 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:3The header comment says
_buildManifestcopies validation except forreferencefields, but the test below asserts that reference fields do receiverelation,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:953createContentTableadds a column for every field, includingreference.createFieldalready guards against this (relations.tsline 616) usingSTORAGELESS_FIELD_TYPES, so admin-created reference fields avoid a column. But seeded collections created viacreateSeedCollectionpassfieldstocreateContentTable, which produces orphanec_* TEXTcolumns 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:376The JSDoc contains
see Task 6.AGENTS.mdcomments 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:349This comment contains
Task 7, which violatesAGENTS.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
…nto feat/reference-field-admin-ui
|
Addressed the latest review in
I verified the other two findings and am not applying them:
Verification after integrating the remote branch:
|
There was a problem hiding this comment.
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:
createContentTablenow skipsSTORAGELESS_FIELD_TYPES(seeded reference fields no longer get orphan columns).- The manifest-reference test docstring now describes the current behavior.
- The
Task 6andTask 7comments have been removed.
Two concrete issues remain unaddressed and are reposted below:
ContentPickerModalstill accepts alocaleprop but never forwards it to the query key orfetchContentList, so the server-side locale filter is bypassed.RESERVED_FIELD_SLUGSstill does not reservereferences, so a user-defined field can shadow the runtime-hydratedreferencespayload.
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:125The modal receives
locale(line 64) and uses it for client-side translation-group collapsing, but neither thequeryKeynor thefetchContentListcall includeslocale. The content-list handler supports server-sidelocalefiltering (handleContentListreadsparams.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
localeto 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-334referencesis a runtime-hydrated property onContentItem(handleContentGet/hydrateReferencesmerges edge data intoitem.references). It is not listed inRESERVED_FIELD_SLUGS, so a user-defined field namedreferencescan 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 liketerms,bylines, andbyline.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", ];
|
The bot keeps parroting the same points. Please ignore its review. |
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)_emdash_content_referencesinstead of anec_*column. Existing reference columns keep their data but are no longer written.referenceskey and are written atomically with the entry in a single transaction; the content GET hydrates them alongside SEO and bylines.title(from the entry'stitle, thenname), so backlinks and picked entries show a readable label rather than a slug.$ref:value as an edge (seed shape unchanged).injectCoreRoutes.Admin (
@emdash-cms/admin)Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (0 diagnostics)pnpm testpasses (core: 4672 passed / 3 skipped; admin: 1065 passed)pnpm formathas been runmessages.pochanges included in this PRemdash: minor,@emdash-cms/admin: minor)AI-generated code disclosure
Screenshots / test output
🤖 Generated with Claude Code
Remains to be fixed
Remains to be verified