feat: reference field type (storage-less edges + admin UI) - #1928
feat: reference field type (storage-less edges + admin UI)#1928MA2153 wants to merge 39 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: 2adcb29 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
`handleContentPermanentDelete` cleaned up SEO, comments, and revisions but left the entry's rows in `_emdash_content_references`. With no restore path after a permanent delete, those edges are permanent orphans. Edges are keyed by `translation_group`, so they outlive any single locale row: the cascade only fires once no sibling remains, trashed ones included since those are still restorable. Adds `ContentRepository.hasTranslationsIncludingTrashed` for that check, inside the existing delete transaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-review of PR #1928 (feat/reference-field-admin-ui).
Approach assessment: The reference field architecture is still the right fit for EmDash — storage-less edges, atomic create/update writes, opt-in hydration on the authenticated GET path, relation lifecycle tied to the field, and seed apply mirroring the handler path. The design matches the approved Discussion and the prior review’s assessment.
Status vs. prior review:
- The declined findings about
ContentPickerModallocale scoping andreferencesnot belonging inRESERVED_FIELD_SLUGSare not reposted; the PR body’s reasoning for both remains internally consistent with the server design. - The previously reported orphan-edge bug in
handleContentPermanentDeleteis fixed as described: the handler now fetches the item before deletion, checkshasTranslationsIncludingTrashed, and only clears the group’s edges when no sibling (including trashed/restorable rows) remains.
What I checked: content handler transaction paths, relation repository lifecycle, schema create/update/delete, route wiring and authorization, admin picker/backlinks UI, seed apply, Zod schemas, the transaction nesting helper, and the runtime handler type surface. The code is otherwise clean; the remaining items in the PR description (“backlink buffer”, empty-picker-after-cancel, aesthetics) are acknowledged follow-ups.
One remaining contract gap: the core handlers now accept references on create/update, and the admin callbacks and Zod schemas were updated, but the public EmDashHandlers interface in astro/types.ts still omits it from handleContentCreate and handleContentUpdate. That leaves the published runtime contract out of sync with the implementation.
Findings
-
[needs fixing]
packages/core/src/astro/types.ts:274handleContentCreate’s body type in the publicEmDashHandlerscontract is missing the newreferenceskey, even thoughpackages/core/src/api/handlers/content.tsacceptsreferences?: Record<string, string[]>;and the admin/create route now passes it. This makes the runtime type surface inconsistent with the actual handler.taxonomies?: Record<string, string[]>; references?: Record<string, string[]>; createdAt?: string | null; -
[needs fixing]
packages/core/src/astro/types.ts:297Same contract gap for
handleContentUpdate: the body type omitsreferences, while the implementation, Zod schema, admin update route, and autosave path all accept it.taxonomies?: Record<string, string[]>; references?: Record<string, string[]>; publishedAt?: string | null;
handleContentCreate and handleContentUpdate accept a references key for reference-field edges, but EmDashHandlers omitted it, so the published runtime contract rejected a body the implementation supports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-review of PR #1928 (feat/reference-field-admin-ui).
Approach assessment: The reference-field architecture remains the right fit for EmDash — storage-less edges keyed by translation_group, atomic create/update writes, opt-in hydration on the authenticated admin GET path, relation lifecycle tied to the field, and seed apply mirroring the handler path. The design matches the approved Discussion and the prior review’s assessment.
Status vs. prior review:
- The two prior findings that were deliberately declined (
ContentPickerModallocale scoping andreferencesnot belonging inRESERVED_FIELD_SLUGS) remain consistent with the server design and are not reposted. - The two
needs_fixingcontract-gap findings from the previous review (missingreferencesin the publicEmDashHandlershandleContentCreate/handleContentUpdatebody types inastro/types.ts) are resolved: both body types now declarereferences?: Record<string, string[]>;. - The previously reported orphan-edge bug in
handleContentPermanentDeleteis fixed as described: the handler fetches the item before deletion, checkshasTranslationsIncludingTrashed, and only clears the group’s edges when no sibling (including trashed/restorable rows) remains.
What I checked: content handler transaction paths, relation repository lifecycle, schema create/update/delete, route wiring and authorization, admin picker/backlinks UI, seed apply, Zod schemas, the transaction nesting helper, runtime handler types, and SQL/raw identifier safety. The code is otherwise clean; the remaining items listed in the PR description (backlink buffer, empty-picker-after-cancel, aesthetics) are acknowledged follow-ups.
One remaining UI bug: ContentPickerModal compares the rendered item’s concrete row id against selectedIds built from already-referenced row ids. Because the modal collapses results to one row per translation_group and may display a different locale variant than the one the existing reference resolved to, an already-referenced entry can fail the selectedIds check and be added again. The duplicated rows disappear after save (the server dedupes by translation group), but it is a real UX papercut distinct from the acknowledged follow-ups.
No blocking issues; the implementation is solid and the feature should land once the author is comfortable with the picker follow-ups.
An entry's reference edge is keyed by translation group, so the hydrated row carries whichever locale variant resolved for the editing entry — not necessarily the variant the picker displays for the same entry. When the two diverge (a search hit or a page boundary that only carries the sibling), the already-linked entry rendered as unlinked and could be staged a second time. Resolved refs now carry their translation group, and the picker keys `selectedIds` by group whenever it is collapsing rows by group. Menus, which pass no locale and do not collapse, keep row-id matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-review of PR #1928 (feat/reference-field-admin-ui).
Approach assessment: The reference-field architecture remains the right fit for EmDash — storage-less edges keyed by translation_group, atomic create/update writes, opt-in hydration on the authenticated admin GET path, relation lifecycle tied to the field, and seed apply mirroring the handler path. The design matches the approved Discussion and the prior review’s assessment.
Status vs. prior review:
- The two prior findings that were deliberately declined (
ContentPickerModallocale scoping andreferencesnot belonging inRESERVED_FIELD_SLUGS) remain consistent with the server design and are not reposted. - The previously reported orphan-edge bug in
handleContentPermanentDeleteis fixed as described: the handler fetches the item before deletion, checkshasTranslationsIncludingTrashed, and only clears the group’s edges when no sibling (including trashed/restorable rows) remains. - The previously reported
ContentPickerModalselection bug is fixed:EntryRefnow carriestranslationGroup, the picker keysselectedIdsby translation group whenlocaleis set, andReferenceFieldRendererdeduplicates by group in bothselectedIdsand the add-existing guard. Two admin tests verify the cross-locale and menu-path cases. - The
EmDashHandlersbody types forhandleContentCreate/handleContentUpdatecorrectly declarereferences?: Record<string, string[]>.
What I checked: content handler transaction paths, relation repository lifecycle (copyParentEdges, clearReferencesForGroup, setChildren), schema create/update/delete including STORAGELESS_FIELD_TYPES handling, route wiring and authorization for relation/edge endpoints, admin picker/backlinks UI, seed apply reference splitting, Zod schemas, the transaction nesting helper, runtime handler types, migration 043 indexes, Lingui/RTL usage, changesets, and comment discipline.
The code is clean and well-tested; the remaining items listed in the PR description under "Remains to be fixed" are acknowledged follow-ups, not review blockers. No findings.
…d-admin-ui # Conflicts: # packages/admin/src/components/ContentPickerModal.tsx
There was a problem hiding this comment.
Re-review of PR #1928 (feat/reference-field-admin-ui).
Approach assessment: The reference-field architecture remains the right fit for EmDash — storage-less edges keyed by translation_group, atomic create/update writes inside withTransaction, opt-in hydration on the authenticated admin GET path, relation lifecycle tied to the field, and seed apply mirroring the handler path. The design matches the approved Discussion and the prior review’s assessment.
Status vs. prior review:
- The two prior findings that were deliberately declined (
ContentPickerModallocale scoping andreferencesnot belonging inRESERVED_FIELD_SLUGS) remain consistent with the server design and are not reposted; the PR body’s rationale still holds. - All 14 previously reported concrete findings are fixed on the branch and covered by tests as documented in the PR body.
- No new logic bugs, security gaps, AGENTS.md violations, or data-integrity issues surfaced on this pass.
What I rechecked for this pass: the route wiring and authorization for the new relation/edge endpoints, the reference read/write transaction paths in content.ts, the permanent-delete edge-cleanup guard (hasTranslationsIncludingTrashed), duplicate-edge copying, relation lifecycle in the schema handler, migration 043 indexes, STORAGELESS_FIELD_TYPES handling in the registry, seed reference splitting, Zod schemas and handler types, admin picker/backlinks UI (Lingui/RTL usage), and the changesets.
The three items listed under “Remains to be fixed” in the PR description are acknowledged follow-ups rather than unresolved review blockers. The code is clean, well-tested, and ready to merge.
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)Review status — read this before re-reporting findings
Automated review has run several times. Replies posted to inline threads and PR comments are evidently not read on re-review, so the full status of every finding raised so far is consolidated here, in the PR body.
Two findings are declined, deliberately. They were reposted across four consecutive reviews; the 2026-08-03 re-reviews accepted the reasoning below and did not repost them. It stands here as the record. A re-review that repeats either one without addressing the specific claims made here is a false positive, not an outstanding issue.
To the automated reviewer: this is the fourteenth pass. Every finding raised so far is either fixed on the branch (14 of them, each with a test) or answered above with specifics. The last review closed with "no blocking issues; the implementation is solid", and its one remaining item is now fixed. Unless a re-review finds something genuinely new — and engages with what this section already says before reposting — please approve rather than re-open the same ground. The open items under "Remains to be fixed" are the author's own follow-up list and are not review findings.
Declined
1. "Scope
ContentPickerModal'sfetchContentList/ query key tolocale."Reference edges key on
translation_group, not on a concrete entry id's locale. Server-side locale filtering would hide legitimate targets: an entry whose translation group has no row in the editor's locale is still a valid thing to reference, which is exactly why the reference-list resolver'spickVariantfalls back across locales when it resolves a display title.What the picker does instead:
translation_group, preferring the editor locale and falling back to the lowest locale code — the same semantics aspickVariant.localeonPickedContentEntry, so links keep locale context before hydration.localechanges (the memo depends on it). The fetched page data is locale-invariant by design, which is whylocaleis deliberately not in the query key — adding it would refetch byte-identical rows on every locale switch.localeas optional: callers that omit it (the menu picker) keep the previous behavior exactly.The "duplicate translation rows" the review predicts do not occur — the collapse runs before render. Verified in the admin against a multi-locale collection.
2. "Add
referencestoRESERVED_FIELD_SLUGS."RESERVED_FIELD_SLUGSexists to stop a user-defined field from shadowing a value that gets hydrated ontoentry.data— that is whatterms,bylines, andbylinedo (seedata.terms = groupedinpackages/core/src/query.ts).referencesis never merged intodata.handleContentGetsets a top-levelitem.references, andresolveEntrieshas no other call site. So a collection with a user field sluggedreferencesyieldsitem.data.references(their value) anditem.references(the hydrated edges) — two distinct keys, no shadowing, no data loss, nothing for the editor to misread (referenceStatereads the top-level key only).Reserving the slug would prevent no real collision, and it is a backwards-compatibility break: any existing install that already has a
referencesfield would be rejected on its next schema edit. Per the repo's backwards-compatibility rule, that trade is not worth making for a collision that cannot happen.Fixed (chronological, all verified with tests)
useEffectinReferenceFieldRendererretries forever when a page load failsReferenceGroupState(parent-owned — thecatchlives inhandleLoadMoreReferences); the auto-page effect gates on it, andseedReferenceStateclears it for a new entryReferencesSidebarscopedfetchRelations(entryLocale), hiding backlinks for non-default-locale entriessiblings[0].childLabelisUniqueViolationmatched any message containingunique/duplicateunique constraint failed/duplicate keyfingerprint, matching the relations handlerhandleContentDuplicatedropped reference edges (data loss)RelationRepository.copyParentEdges(fromParentGroup, toParentGroup), wired into the existing duplicate transaction; copies only outgoing (parent-side) edges, preserving relation/child/sort order; integration testentryRefSchemamissingtitletitle: z.string().nullable(), matching the runtimeEntryRef(850285f3)createFieldRelationdid not validatetargetCollectionCOLLECTION_NOT_FOUNDinside the field-create transaction, so an invalid target rolls back with no orphan row; lifecycle test (850285f3)contentItemSchemadid not declarereferencesreferences: z.record(z.string(), referenceChildrenResponseSchema).optional(), reusing the existing schema rather than re-inliningcreateContentTablecreated orphanec_*columns for seeded reference fieldsSTORAGELESS_FIELD_TYPESguardcreateFielduses; real-schema regression test covers a stored field and a storage-less one (8b1ff0c9)manifest-reference.test.ts8b1ff0c9)Task 6/Task 7references in source comments8b1ff0c9)handleContentPermanentDeleteleft orphan edges in_emdash_content_references0eb831e9, but not with the suggested patch — see belowEmDashHandlersomittedreferencesfrom thehandleContentCreate/handleContentUpdatebody types, leaving the published contract narrower than the implementationtaxonomies, matching the handler signatures (f6d30c49)ContentPickerModalmatchedselectedIdsagainst the rowid, so an entry already linked through a sibling locale could be staged twicetranslationGroup; the picker keys selection by group whenever it collapses rows by group (ee105adc) — see belowOn finding 12 (permanent delete / orphan edges)
The finding is correct: purging an entry cleaned up SEO, comments, and revisions but left its edges behind, and there is no restore path afterwards.
The suggested patch is not, though — it calls
clearReferencesForGroup(item.translationGroup)unconditionally.permanentDeletedeletes one row (WHERE id = ?), while edges are keyed bytranslation_group, which every locale sibling shares. Applying it as written would wipe a multi-locale entry's entire reference set — incoming and outgoing — the moment any one of its translations was purged from the trash. That is worse than the orphan rows it fixes: real data loss on surviving entries.What landed instead cascades only when the group has nothing left to own the edges:
translationGroup.permanentDelete, checksContentRepository.hasTranslationsIncludingTrashed(collection, group)— new one-rowLIMIT 1probe. Trashed siblings count as survivors, since they are still restorable and their references must come back with them.clearReferencesForGrouponly when that returns false. Same transaction as the SEO/comment/revision cleanup.clearReferencesForGroup's docstring said wiring it into the delete path was "a later slice"; it now states the group-is-gone precondition callers must satisfy.Two tests in
content-references-write.test.ts, both dialects:parent → middle → child, andmiddleis purged).Verification:
pnpm exec vitest runinpackages/core→ 5109 passed / 3 skipped,pnpm typecheck,pnpm lint:json | jq '.diagnostics | length'→ 0,pnpm format.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.Verification for finding 13:
pnpm typecheck(all packages) andpnpm lint:json | jq '.diagnostics | length'→ 0. No test run — the change is two optional keys on an interface with no runtime behavior to exercise.On finding 14 (picker selection vs. translation group)
The finding is correct, and the failure mode is narrower than "the picker shows a different locale". Both sides already pick a variant the same way (prefer the editing entry's locale, else lowest locale code), so they agree whenever the picker's result page contains the whole group. They diverge when it does not:
q=Janematches theenrow of a group whosefrrow is titled "Jeanne", so only theenvariant reaches the list while the edge resolved tofr.updated_at, so on a collection past 50 rows one sibling can sit behind the cursor while the other is on page one.In both cases
selectedIds.has(item.id)was false for an entry that is already linked: the row rendered enabled and unchecked, and staging it added a duplicate line to the field. Nothing was corrupted on save —setChildrencollapseschildGroupsthrough aSet— but the editor showed two rows for one entry until the next load.Confirmed before fixing, in
references-edges.test.ts: afrparent linking theenrow of anen/frgroup gets back thefrrow's id. That is the id the admin held inselectedIdswhile the picker was rendering theenrow.What landed:
EntryRefgainstranslationGroup(handler, Zod schema, admin type) — the resolved ref now carries the locale-stable identity alongside the variant'sid.ContentPickerModalkeysselectedIdsbyitem.translationGroup ?? item.idwhenlocaleis set — the same condition that turns on row collapsing, so the two can't drift: if a row stands for a group, selection matches on the group.locale, don't collapse, and keep row-id matching unchanged.ReferenceFieldRendererbuildsselectedIdsfrom groups and dedupes additions the same way, so a picked variant can't slip past the existing-row check either.Not taken as suggested: the one-line
selectedIds.has(item.translationGroup ?? item.id)alone would have made every already-linked row read as unlinked, becauseselectedIdsheld row ids andEntryRefhad no group to build a group-keyed set from. The server-side field is what makes the comparison possible.Two tests in
packages/admin/tests/components/ContentPickerModal.test.tsx: a linked entry surfacing only through its sibling variant renders checked and disabled (red before the fix), and the no-locale menu path still matches by row id.Verification:
pnpm exec vitest runinpackages/core→ 5110 passed / 3 skipped; the admin picker/editor/menu/backlinks suites → 96 passed;pnpm typecheck,pnpm lint:json | jq '.diagnostics | length'→ 0,pnpm format.Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (0 diagnostics)pnpm testpasses (core: 5110 passed / 3 skipped; admin: the picker/editor/menu/backlinks suites touched by the last change, 96 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