Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
26b87a0
feat(core): mark reference as a storage-less field type
MA2153 Jul 10, 2026
adeb478
feat(core): registry skips column DDL for storage-less fields
MA2153 Jul 10, 2026
159dd3a
feat(core): strip storage-less field keys from content data writes
MA2153 Jul 10, 2026
7144b9c
feat(core): manifest carries reference field validation config
MA2153 Jul 10, 2026
4282dc9
refactor(core): extract shared setReferenceChildren resolver
MA2153 Jul 10, 2026
0dc4933
feat(core): write reference edges atomically with the content entry
MA2153 Jul 10, 2026
7f6df80
fix(core): expose references key in content body schemas
MA2153 Jul 10, 2026
f8426f6
feat(core): hydrate references into the content GET response
MA2153 Jul 10, 2026
6049e0d
feat(core): schema handlers own the reference field relation lifecycle
MA2153 Jul 10, 2026
26eeaba
fix(core): keep reference field relation link immutable on schema update
MA2153 Jul 10, 2026
e3be4f5
feat(admin): relations read API client
MA2153 Jul 10, 2026
706f9c3
feat(admin): reference field config step in the schema editor
MA2153 Jul 10, 2026
0c0b30f
feat(admin): reference field renderer with staged, atomic save
MA2153 Jul 10, 2026
563c56f
feat(admin): referenced-by backlinks sidebar
MA2153 Jul 10, 2026
b8c4666
fix(core): drop unsafe FieldType cast in storage-less key strip
MA2153 Jul 10, 2026
6537154
fix(core): apply reference fields as edges in the seed engine
MA2153 Jul 10, 2026
7c6d008
chore: changesets for reference field type
MA2153 Jul 10, 2026
6861a4e
feat(reference): display titles, route wiring, and config validation
MA2153 Jul 10, 2026
50494ff
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Jul 10, 2026
95e8c2b
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Jul 10, 2026
080a039
Allow navigation to referenced items
MA2153 Jul 11, 2026
0556f8e
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Jul 11, 2026
25664b6
Update changesets
MA2153 Jul 11, 2026
45e018f
Address review
MA2153 Jul 11, 2026
850285f
Address re-review: schema title, target validation, locale badges
MA2153 Jul 11, 2026
6f6d052
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Jul 11, 2026
4dad09b
Address review
MA2153 Jul 11, 2026
bd97d0a
Thread entry locale to prevent dedupe from shortcircuiting
MA2153 Jul 11, 2026
a442e19
Merge remote-tracking branch 'upstream/main' into feat/reference-fiel…
MA2153 Jul 21, 2026
8a5941a
style: format
emdashbot[bot] Jul 21, 2026
8b1ff0c
fix(core): keep seeded reference fields storage-less
MA2153 Jul 22, 2026
3a69da0
Merge remote-tracking branch 'origin/feat/reference-field-admin-ui' i…
MA2153 Jul 22, 2026
79da749
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Jul 22, 2026
8e87f3e
Merge remote-tracking branch 'upstream/main' into feat/reference-fiel…
MA2153 Aug 3, 2026
0eb831e
fix(core): clear reference edges when the last row of a group is purged
MA2153 Aug 3, 2026
f6d30c4
fix(core): declare references on the public content handler contract
MA2153 Aug 3, 2026
ee105ad
fix(admin): match picker selection by translation group
MA2153 Aug 3, 2026
899adf3
Merge remote-tracking branch 'upstream/main' into feat/reference-fiel…
MA2153 Aug 6, 2026
2adcb29
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Aug 8, 2026
680c1b2
fix: recover from a failed reference page and drop legacy reference c…
MA2153 Aug 11, 2026
4b3d402
Merge branch 'main' into feat/reference-field-admin-ui
MA2153 Aug 11, 2026
a3eed04
Merge upstream/main into feat/reference-field-admin-ui
MA2153 Aug 13, 2026
808cde8
Merge upstream/main into feat/reference-field-admin-ui
MA2153 Aug 16, 2026
a4c5117
Merge upstream main into reference field work
saariuslystoned Aug 30, 2026
ee92c6f
fix(core): enforce reference field constraints
saariuslystoned Aug 30, 2026
20bb470
fix(core): validate storage-less reference inputs
saariuslystoned Aug 30, 2026
cf14612
fix: harden reference fields for D1
saariuslystoned Aug 30, 2026
5e557de
Merge pull request #1 from saariuslystoned/codex/emdash-1928-merge-re…
MA2153 Aug 31, 2026
afbf6aa
Merge upstream/main into feat/reference-field-admin-ui
MA2153 Sep 10, 2026
18d8c63
feat(core): relations as first-class schema objects
MA2153 Sep 10, 2026
3990224
feat(core): relation usage reporting, cascades and cardinality
MA2153 Sep 10, 2026
746a8f5
feat(core): storage-less is a property of the field row
MA2153 Sep 10, 2026
2d1eb11
feat(core): bind reference fields that predate relations
MA2153 Sep 10, 2026
96e0209
docs: reference fields link through a relation
MA2153 Sep 10, 2026
836a2da
feat(core): declare relations in a seed
MA2153 Sep 11, 2026
1d32f5f
feat(core): address a reference selection by field slug
MA2153 Sep 11, 2026
bc6b18b
feat(core): a reference selection follows the draft
MA2153 Sep 11, 2026
764cf7c
feat(core): site code can read an entry's references
MA2153 Sep 11, 2026
608ca6c
feat(core): an entry's references are cached with the entry
MA2153 Sep 11, 2026
f193e3e
feat(core): generated types cover an entry's references
MA2153 Sep 11, 2026
da60407
docs: reading an entry's references from site code
MA2153 Sep 11, 2026
9a12ab5
fix(admin): backlinks appear again in the Referenced by panel
MA2153 Sep 11, 2026
005a519
feat(admin): relations have a schema surface
MA2153 Sep 11, 2026
e6c6ce2
feat: a reference field can use a relationship that already exists
MA2153 Sep 11, 2026
df6a800
feat(admin): deleting says which relationships and fields go with it
MA2153 Sep 11, 2026
3fbf38a
docs: changesets cover the relations admin surface
MA2153 Sep 11, 2026
778a7fe
Address review
MA2153 Sep 12, 2026
e62a809
Improve editor for reference fields
MA2153 Sep 12, 2026
a7e5564
Show relations under content types details
MA2153 Sep 12, 2026
4afde38
Lay the relation form out in two columns
MA2153 Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/reference-field-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@emdash-cms/admin": minor
---

Reference fields are now a real, working field type. Previously "reference" was just a plain text box with nowhere to point; now you get a proper relationship picker. Configure it in the schema editor (choose the target collection and single vs. multiple), then search for, pick, and reorder linked entries right in the entry editor — all saved together with the entry in one request. Referenced entries show a read-only "Referenced by" panel so you can see what points at them, and you can jump straight to any linked entry from the picker or the backlinks.

A reference field created before this release has no target collection, so the entry editor keeps showing it as the text box it has always been, alongside a note about setting a target collection under Content Types to get the picker. Its field dialog offers the collection picker rather than disabling it, pre-filled with whatever target the field already named, and says what saving one does: the field becomes an entry picker, its stored entry IDs move to the relationship, and it stops being searchable and filterable. A field that never recorded whether it allowed more than one entry now shows as single rather than multiple, which is what the API and the upgrade migration both assume — binding one by hand and letting it upgrade on its own now give the same limit. New reference fields default to single for the same reason.

#### Relationships have their own page

Content Types links to a Relations page listing every relationship on the site: the two content types it joins, the reference fields bound to each end and which end they pick from, and how many links it holds. Relationships with no field bound to them are listed too — clearing the checkbox on a field delete leaves one behind, and without this they would be unreachable.

A content type's own page repeats the ones it is an end of, in a Relations panel under its fields: which content types the relationship joins, the role this content type plays in it, the fields on this content type bound to it, and how many links it holds. A relationship no field here uses says so, which is what a field delete leaves behind. New Relation opens the same form the Relations page uses, as a dialog, with this content type filled in as the linking end.

A relationship can be created from either, ahead of any field that uses it, and its role names and limits edited afterwards. The two content types and the slug are fixed once it exists: a reference field stores the slug and every link is keyed by the relationship, so moving an end would repoint stored links at content of the wrong type. Limits read as One, Any number, or an explicit maximum, per side, and live on the relationship rather than on each field so two fields bound to it cannot disagree about the same links.

The field dialog offers the relationships this content type can still bind to, ahead of the referenced-collection picker; choosing one takes the referenced collection and the limits from it. The direction is a choice only for a relationship whose two ends are the same content type, and stated read-only everywhere else, with a note on why the linked end offers no reordering: a link's position is scoped to the entry that made it, so only the linking end can order its selection.

The first choice in that picker is Quick create a relationship, which is also what a site with no relationships yet gets. Picking it still makes a relationship, named after the field and its content type, taking its linking-side limit from the Allow multiple references switch and leaving the other side unlimited, and the dialog now says so — and links to the Relations page, in a new tab so the half-filled field survives, for anyone who would rather set the slug, the role names, and the limits themselves and then come back and pick it.

#### Deleting says what goes with it

Deleting a reference field offers to delete the relationship it uses, checked by default, and the dialog names what that takes: the relationship, how many links it holds, and the field on the other content type with the direction it picks from. That last part matters most when the field being deleted is the inverse one — deleting a field on Authors would otherwise silently remove the primary field on Posts. Clearing the checkbox keeps the relationship and its links.

A relationship can be deleted from its own page. It does not refuse when fields are bound to it: it names them and removes them. Deleting a content type lists every relationship it is an end of, and the fields on other content types that go with them, next to the existing content warning.
153 changes: 153 additions & 0 deletions .changeset/reference-field-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
"emdash": minor
---

Adds reference fields that store relationships between entries. Selections are written atomically with the entry and are hydrated on read alongside SEO and bylines. Each resolved reference includes a display title from the referenced entry's configured title field, `title`, or `name`, so pickers and backlinks show a readable label.

A selection is addressed by field slug, in the entry create and update bodies and in the `references` an entry read returns:

```jsonc
// POST /_emdash/api/content/posts
{ "data": { "title": "Hello" }, "references": { "author": ["01HXK5MZSN..."] } }
```

A field bound to the child end of its relation selects the entries pointing at it, and those have no order of their own: `sort_order` positions children within one parent, and nothing positions a child's parents. The relation-scoped routes, `/content/{collection}/{id}/references/{relation}/children` and `/parents`, still address a relation — that is what they are about.

#### Reference selections are versioned

On a collection that keeps revisions, changing a picker on a published entry no longer changes the published page. The new selection is staged in the entry's draft alongside its other pending edits and becomes live when the entry is published — through the publish action, a scheduled publish, or restoring a revision. Discarding the draft discards the selection with it, and duplicating an entry copies the published selection rather than the source's pending one.

An entry read that includes drafts, which is what the admin does, reports the staged selection; a public read reports the published one. A collection created without `revisions` support keeps writing a selection straight through, as do entry creations, which have no published version to differ from.

Publishing re-checks the staged selection against the relation's limits, so a draft cannot carry a selection past a schema change that would now reject it — the publish fails with `VALIDATION_ERROR` and the published selection stands.

Comparing an entry's live and draft revisions now reports `_references` on both sides, filled in from the published selection for the fields a draft did not stage, so an unchanged reference field does not read as one the draft removed.

#### Reading references from site code

`getEmDashEntry` takes a `references` option naming the fields a page actually renders, by field slug, and returns a page of entries for each:

```ts
const { entry: post } = await getEmDashEntry("posts", slug, {
references: { author: true, related_posts: { limit: 6 } },
});

const author = post?.references?.author.entries[0];
for (const related of post?.references?.related_posts.entries ?? []) {
// related.id, related.data.title, related.edit
}
```

It is opt-in in both directions: a call that passes no `references` issues no extra queries, and a field left out of the selection is not read. A call that does select fields costs one read of the collection's reference-field map, then one link read per field plus one entry read per _distinct_ target collection, however many entries each field holds — so a page asking for an author and six related posts is one field-map read, two link reads and two entry reads, not eight. The link reads run concurrently, and the field map is cached per request and in the schema object-cache namespace.

Those reads go into the entry's cached snapshot, so a site with an object cache configured pays them on a miss and not on a hit, and publishing a referenced entry drops the snapshots that carry it. The `cacheHint` the call returns names every referenced row the render read and takes the newest modification time across the entry and its references, so passing it to `Astro.cache.set` expires a route-cached page when a referenced entry changes, not only when the entry itself does.

A referenced entry is a `ContentEntry` like any other: the same `id`, the same `data` — dates as `Date`, booleans as booleans, media values resolved — and a working `edit` proxy in visual editing, scoped to the referenced entry so clicking through opens the entry the card is about. Bylines and taxonomy terms are not hydrated onto referenced entries; read those from the entry itself when a card needs them.

Entries come back in the order the editor arranged them for a field on the parent end of its relation. A field on the child end lists whatever points at it, which has no order of its own.

A public render sees published entries only, and sees the published selection. A preview of that entry, or an editor in visual editing, sees unpublished entries and the pending selection staged in the draft — so a preview link shows the references the page will have once it is published.

`getEmDashReferences` walks past the first page using the cursor that page returned, for a field holding more entries than one page shows:

```ts
const more = await getEmDashReferences("posts", post.id, "related_posts", {
cursor,
limit: 20,
});
```

Both default to 50 entries per field and accept at most 100.

Generated types cover references. A collection with at least one bound reference field gets a `{Collection}References` interface beside its data interface, registered under the collection slug the same way, so `getEmDashEntry` narrows its result to the fields the call named and each page's entries carry the target collection's interface:

```ts
const { entry: post } = await getEmDashEntry("posts", slug, { references: { author: true } });

// post?.references?.author.entries[0].data is an Author
// post?.references?.related_posts is a type error: it was not selected
```

Re-run `emdash types`, or restart the dev server, to pick the interfaces up. A reference field that is not bound to a relation keeps its `string` key in the data interface, as it keeps its column.

Reference fields enforce required and selection-limit constraints for entry saves and direct reference requests, on both ends of the relation: a field on the parent side that would hand a selected entry more parents than the relation allows is refused, not only one that selects too many children itself. A collection that keeps drafts re-checks the whole selection at publish rather than only the fields a draft happens to have staged, so a required reference field added to a collection that already holds entries blocks publishing them until it is filled in.

Reference selections are shared across translations, so creating a translation reuses the source entry's selection. Duplicating an entry carries its selections onto the copy, including a field bound to the child end of its relation, whose value _is_ the entries pointing at it. Backlinks no field views still point only at the original.

A reference field stores no column of its own once it is bound to a relation; its selection lives as edges in `_emdash_content_references`. A reference field created before relations existed is not bound to one, so it keeps the column it has and behaves as it always has: the entry id it holds saves, loads, validates against the collection schema, and appears in generated types as a `string`, and the field can still be indexed and used as a content-list filter. Seed files continue to use `$ref:` values, which resolve to an edge for a bound field and to a column value for an unbound one.

A bound reference field cannot be marked as indexed, because it has no column to index. Large reference replacements are split into D1-safe writes while preserving selection order.

#### Upgrading a site with existing reference fields

Migration 077 binds each reference field that named its target collection — in `options.collection`, as the `reference()` field helper and the documented seed shape do — to a new relation, and copies the entry ids in its column in as links. Those fields become working pickers on upgrade with their existing selections intact.

A reference field is left alone, and keeps behaving exactly as it did, when:

- it names no target collection, or names one that no longer exists. A reference field created in the admin before this release has no target, since the admin had nowhere to record one.
- it is marked searchable or indexed. Both mean the site queries that column through an index, and binding the field stops the column being written.
- the relation slug it would take, `{collection}_{field}`, is already in use.

To bind one of those fields yourself, open it under Content Types and choose a referenced collection. EmDash creates the relation, copies the column's ids in as links, and clears the field's searchable and indexed flags — after which `fields` filters and site search no longer cover it.

The column is left in place and stops being written. On a site that predates pickers it was a free-text box that could hold anything an editor typed, and only the ids that resolved to an entry became links, so nothing is deleted. Generated types no longer declare the key for a bound field, but a content read still reports the frozen column value in `data` beside the live `references`.

`relations` joins the reserved collection slugs: the admin serves the relations screen at that path, so a collection with that slug could never be opened.

Relations are now first-class schema objects rather than a hidden detail of each reference field. A relation joins two collections under a slug that is unique across the site, and a reference field records which end of that relation it sits on — so the same relation can back a field on either side. A relation carries a label and an optional singular form for each role, plus an optional limit on how many entries each side may hold.

Migration 076 restructures `_emdash_relations` to match: the per-locale rows collapse into one row per relation, keyed by a new unique `slug`, and `_emdash_content_references.relation_group` becomes `relation_id`. Relation ids are preserved, so existing reference edges stay valid. Relations are no longer localized — like collections and fields, their labels are single-valued. Where per-locale rows existed, the lowest locale code's labels win.

Deleting a reference field no longer deletes its relation by default. The relation and its edges survive until they are deleted deliberately, either from the relations admin or by opting in on the field delete, which also removes the field bound to the relation's other side. Deleting a collection removes every relation it is an end of, along with the reference fields viewing them — including fields on the collection at the far end, which would otherwise address a collection that no longer exists.

Reading a relation now reports what deleting it would take: the reference fields bound to it and how many links it holds.

Seed files gain a top-level `relations` array, so a relation can be declared with its labels and limits instead of being created as a side effect of the first reference field that needs one:

```json
{
"relations": [
{
"slug": "post_authors",
"parentCollection": "posts",
"childCollection": "authors",
"parentLabel": "Posts",
"childLabel": "Authors",
"maxChildrenPerParent": 1
}
],
"collections": [
{
"slug": "posts",
"label": "Posts",
"fields": [
{
"slug": "author",
"label": "Author",
"type": "reference",
"validation": { "relation": "post_authors" }
}
]
}
]
}
```

A reference field created through the schema API can name a relation the same way. `POST /_emdash/api/schema/collections/{slug}/fields` accepts `validation.relation`, and `validation.relationSide` for a relation whose two ends are the same collection, and binds the field to it instead of creating a relation:

```jsonc
{
"slug": "author",
"label": "Author",
"type": "reference",
"validation": { "relation": "post_authors" },
}
```

The referenced collection and the selection limits come from the relation, so a `targetCollection` sent alongside a relation is ignored. Naming a relation the collection is not an end of, a side that contradicts the end that matches, or an end another field already picks from is refused — two fields picking from the same end of a relation would write the same links, and the second save would overwrite the first. A field that names only a `targetCollection` still gets a relation created for it, as before.

A field that names a relation binds to it; the side it views follows from which end its collection sits on, and `relationSide` is needed only for a relation whose two ends are the same collection. A field that names only a `targetCollection` still gets a relation created for it. Re-applying a seed updates a relation's labels and limits under `onConflict: "update"`, but a seed naming different collections for an existing relation fails rather than leaving its links pointing into a collection that is no longer an end of it.

Re-applying a seed that names a `targetCollection` for a reference field that predates relations binds that field, creating the relation and copying the column's ids in as links — the same path the admin takes — rather than leaving an upgraded site's field unbound forever. A seed can select from either end: a field bound to the child side takes the entries that point at it. A `$ref:` that names a collection emitted later in the file is skipped with a warning instead of aborting the whole apply.

`emdash export-seed` emits those relations, and `--with-content` emits each entry's links as `$ref:` values on the parent side of the relation, so a site's reference selections survive an export and re-apply. Entry IDs in a reference field with no relation are emitted as `$ref:` too; previously they were emitted as a reference to the source database's row id, which resolved to nothing on apply.
1 change: 1 addition & 0 deletions demos/simple/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Post {
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> };
content?: PortableTextBlock[];
excerpt?: string;
relevant_posts?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
Expand Down
16 changes: 10 additions & 6 deletions docs/src/content/docs/concepts/collections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,16 @@ EmDash supports 16 field types that map to SQLite column types.
```
</TabItem>
<TabItem label="reference">
Reference to another collection's entry. Stores entry ID as `TEXT`.
Links to entries in another collection. Adds no column: the links live in
`_emdash_content_references` and reads return them under `references`.

```ts
{
slug: "author",
type: "reference",
label: "Author",
options: {
collection: "authors"
validation: {
targetCollection: "authors"
}
}
```
Expand Down Expand Up @@ -291,13 +292,16 @@ The following reference field links to multiple products:
slug: "relatedProducts",
type: "reference",
label: "Related Products",
options: {
collection: "products",
allowMultiple: true
validation: {
targetCollection: "products",
multiple: true
}
}
```

See [`reference`](/reference/field-types/#reference) for where a reference field's links are stored
and how a field with no target collection behaves.

## Querying collections

Use the provided query functions to fetch content. These follow Astro's live collections pattern, returning structured results. The following example shows the common query options:
Expand Down
Loading
Loading