Skip to content

feat(core): expose references to the public content query API - #2509

Closed
MA2153 wants to merge 5 commits into
emdash-cms:mainfrom
MA2153:feat/reference-public-query
Closed

feat(core): expose references to the public content query API#2509
MA2153 wants to merge 5 commits into
emdash-cms:mainfrom
MA2153:feat/reference-public-query

Conversation

@MA2153

@MA2153 MA2153 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stacked on #2503. Base branches can't live on a fork, so this PR targets main and its diff currently includes the three commits below it. Review 04088ee7 on its own — that's the whole of this change.

Closes the first of the two P1 findings on #1928 (r3757946584): a site could select a reference in the admin and had no way to render it. getEmDashEntry and getEmDashCollection now attach entry.references.

const { entry } = await getEmDashEntry("posts", slug);
for (const related of entry.references.related_posts ?? []) {
  related.data.title;   // the referenced entry's own data
  related.slug;
  related.locale;       // which variant answered
}

Keyed by field slug, not by relation group. The relation group is an internal id a site author never sees; the field slug is what they named. (The admin's editor GET keys by relation group — it addresses fields by relation, and that stays as it is.)

Each reference carries the target's data. The batched resolve already has the full row, so returning a summary and making templates re-query for a title would cost more and do less.

Published targets only. An edge pointing at a draft resolves to nothing and is skipped, exactly like an edge whose target was deleted — a public read must never leak an unpublished entry's slug through a reference.

Locale. References are keyed by translation group, so a target that exists only in another language is still a real reference. Resolution prefers the reading entry's locale and falls back, reporting which variant answered in locale rather than presenting a wrong-locale entry as if it matched.

Making unconditional hydration free

Hydration is always on, so the cost when a site doesn't use references has to be zero round trips — not "one cheap one". A first attempt did a schema lookup per render to discover whether the collection had reference fields, and pnpm query-counts caught it immediately:

GET /posts/building-for-the-long-term (cold): expected=17 actual=18

The fix is the existing folded-probe pattern: the content query carries (SELECT 1 FROM _emdash_content_references LIMIT 1), an uncorrelated subquery evaluated once per statement, the same shape as _emdash_bylines_exist. An empty edge table means no field anywhere can have a selection, so hydration stops before it touches the schema.

Final snapshot: every route's query count is unchanged, including that one. scripts/query-counts.snapshot.sqlite.json has no diff at all; only query-counts.queries.sqlite.json moves, because every content query now carries the probe.

For a site that does use references, a page costs a constant number of queries — one batched edge read for the whole page (getChildrenForParents, chunked at SQL_BATCH_SIZE), then one resolve per target collection, so two fields pointing at the same collection share it.

Types

The generated collection interface no longer lists reference fields — they hold no value in data, so relevant_posts?: string was describing something that never existed. entry.references is typed on ContentEntry. fieldTypeToTypeScript returns never for a reference now, so anyone reaching for that type gets an error rather than a plausible-looking string. The reference() factory documents where the value actually lives.

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. fix(core): enforce required and multiple on reference fields #2503 — server-side required / multiple enforcement
  4. This PR — 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  468 passed | 2 skipped (470)
      Tests  5808 passed | 9 skipped (5817)

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

$ pnpm query-counts             # no count drift on any route
$ git diff --stat scripts/
 scripts/query-counts.queries.sqlite.json | 80 ++++++++++++++--------------
 1 file changed, 40 insertions(+), 40 deletions(-)

tests/unit/query-references-hydration.test.ts drives the real loader against a real SQLite database with a query counter on Kysely's log hook — no repository mocks, so the query-count assertions are against actual SQL:

  • referenced entries come back with their own data, in edge order, on both the collection and the single-entry path;
  • a draft child is omitted from a public read;
  • a collection with no reference fields issues no reference queries and no _emdash_fields lookup;
  • a collection that has a reference field but no edges also issues neither — the field existing is not enough to pay for a lookup;
  • a page of five entries costs exactly one batched edge read, not one per entry.

MA2153 and others added 4 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>
`getEmDashEntry` and `getEmDashCollection` now attach `entry.references`,
keyed by field slug, with each referenced entry carrying its own `data` so
a template can render a related entry without a follow-up query. Published
targets only — an edge pointing at a draft resolves to nothing, exactly
like one whose target was deleted, so a public read cannot leak an
unpublished slug.

Hydration is unconditional, so it has to be free when unused: the content
query carries an uncorrelated existence probe on the edge table (the same
shape as `_emdash_bylines_exist`), and an empty table stops hydration
before it looks at the schema. Query counts are unchanged on every route
in the snapshot; only the query text moves.

A page of entries costs a constant number of queries — one batched edge
read for the whole page, then one resolve per target collection.

Generated types no longer describe a reference field as a string on
`data`, and the `reference()` factory documents where the value actually
lives.

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: 6758e0d

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.

The PR solves a clear gap: reference selections were reachable in the admin but invisible to public templates. The approach — a folded existence probe in the content query, one batched edge read per page, and per-target-collection resolution — is consistent with how EmDash already hydrates bylines and taxonomy terms, and the dedicated query-count test is the right shape (real SQLite, real loader, actual SQL logged).

I found two cache-correctness issues that don't show up in the uncached query-count tests but will bite in production:

  1. getReferenceFields relies on CacheNamespace.SCHEMA, but schema mutations never invalidate it. The PR description says "Schema mutations already bust CacheNamespace.SCHEMA", but invalidateSchemaObjectCache() is only used for URL-pattern invalidation in query.ts; createField/updateField/deleteField etc. do not call it. After adding a reference field, public reads may keep serving the cached empty field list until the SCHEMA entry expires.

  2. Cross-collection references are cached without invalidation. entrySnapshot/reviveEntry store hydrated references inside the collection-scoped L2 cache. Publishing or editing a referenced child entry invalidates the child's content namespace, not the parent's, so parent pages can serve stale references until the parent's own cache entry expires. Bylines and taxonomies avoid this by including CacheNamespace.BYLINES/TAXONOMIES in contentNamespaces; references have no equivalent hook.

Both are needs_fixing correctness issues. Fix option for #1 is to stop using the distributed SCHEMA cache for reference-field metadata and rely on the per-request cache (the edge-table probe already prevents the lookup when no references exist). Fix option for #2 is to stop caching references in the snapshot and rehydrate after revive.

The rest of the change is solid: query counts are held flat for uncached logged-out routes, the locale-fallback logic is correct, and the type changes accurately reflect that reference values live on entry.references, not in entry.data. I am not requesting changes because these are correctness/staleness regressions rather than security, data loss, or build breaks; a maintainer should decide whether to address them before merge or in a fast follow-up.


Findings

  • [needs fixing] packages/core/src/schema/reference-fields.ts:114

    getReferenceFields caches under CacheNamespace.SCHEMA, and the comment above it claims schema mutations already invalidate that namespace. That is not true: invalidateSchemaObjectCache() is defined but is only called from invalidateUrlPatternCache() in query.ts; no field/collection create/update/delete path calls it. After an admin adds a reference field, public reads may continue to see the old empty field list until the SCHEMA cache entry expires.

    The cheapest fix is to stop distributed-caching this metadata. The request cache still dedupes multiple calls per render, and the folded _emdash_references_exist probe means the lookup is skipped entirely when the edge table is empty, so a site without references pays nothing.

    export function getReferenceFields(collection: string): Promise<ReferenceFieldConfig[]> {
    	return requestCached(`reference-fields:${collection}`, async () =>
    		referenceFields(await getDb(), collection),
    	);
    }
    
  • [needs fixing] packages/core/src/query.ts:691

    entrySnapshot copies references into the cached snapshot. The content cache key is scoped to the parent collection (contentNamespaces(type)), so publishing or editing a referenced child in a different collection only invalidates the child collection's cache — the parent page keeps serving the old references array.

    Bylines and taxonomies avoid exactly this by including CacheNamespace.BYLINES and CacheNamespace.TAXONOMIES in contentNamespaces. There is no equivalent cross-collection hook for references.

    Drop references from the snapshot so cache hits are rehydrated from the current edges, just like cache misses:

    function entrySnapshot<D>(entry: ContentEntry<D>): Record<string, unknown> {
    	const data = entryData(entry);
    	const rawCursor = Reflect.get(data, CURSOR_RAW_VALUES);
    	// Drop the `edit` function and `references`; both are derived per-render.
    	const { edit: _edit, references: _references, ...rest } = entry as ContentEntry<D> & {
    		edit?: unknown;
    	};
    	return {
    		...rest,
    		data: { ...data, [CURSOR_RAW_FIELD]: rawCursor ?? {} },
    	};
    }
    

    After this change, callers that revive cached snapshots need to run hydrateEntryReferences (and initialize entry.references = {} first if the type is strict) so a cache hit does not leave references undefined.

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 4,022 lines across 42 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 area/core size/XL review/awaiting-author Reviewed; waiting on the author to respond labels 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@2509

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 6758e0d

@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 Aug 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

Labels

area/core overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant