From 26b87a0f18e8721dcbe1052c92e0c62af1f25bf8 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:42:32 +0300 Subject: [PATCH 01/26] feat(core): mark reference as a storage-less field type --- packages/core/src/schema/types.ts | 14 ++++++++++++++ packages/core/tests/fields/reference.test.ts | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6d5055e981..7de23db141 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -75,6 +75,14 @@ export const FIELD_TYPE_TO_COLUMN: Record = { repeater: "JSON", }; +/** + * Field types that persist no `ec_*` column — their values live elsewhere. + * `reference` stores edges in `_emdash_content_references` (see migration 043), + * never a column on the content table. The `FIELD_TYPE_TO_COLUMN` entry above is + * retained deliberately: it doubles as the `isFieldType` guard. + */ +export const STORAGELESS_FIELD_TYPES: ReadonlySet = new Set(["reference"]); + /** * Features a collection can support */ @@ -142,6 +150,12 @@ export interface FieldValidation { minItems?: number; // For repeater fields maxItems?: number; // For repeater fields allowedMimeTypes?: string[]; + /** Reference fields: the relation's translation_group (edge endpoints resolve it). */ + relation?: string; + /** Reference fields: child collection slug (denormalized, immutable on the relation). */ + targetCollection?: string; + /** Reference fields: allow selecting more than one entry (UI constraint). */ + multiple?: boolean; } /** diff --git a/packages/core/tests/fields/reference.test.ts b/packages/core/tests/fields/reference.test.ts index 78095ffb83..017627b7f2 100644 --- a/packages/core/tests/fields/reference.test.ts +++ b/packages/core/tests/fields/reference.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { reference } from "../../src/fields/reference.js"; +import { STORAGELESS_FIELD_TYPES, FIELD_TYPE_TO_COLUMN } from "../../src/schema/types.js"; describe("reference field", () => { it("should create field definition", () => { @@ -38,3 +39,12 @@ describe("reference field", () => { expect(() => optional.schema.parse(undefined)).not.toThrow(); }); }); + +describe("storage-less field types", () => { + it("marks reference as storage-less but keeps its column-type guard entry", () => { + expect(STORAGELESS_FIELD_TYPES.has("reference")).toBe(true); + expect(STORAGELESS_FIELD_TYPES.has("string")).toBe(false); + // The map still contains reference so isFieldType() keeps recognizing it. + expect(FIELD_TYPE_TO_COLUMN.reference).toBe("TEXT"); + }); +}); From adeb478606cd943355762c788626f84dd4edb8e0 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:49:29 +0300 Subject: [PATCH 02/26] feat(core): registry skips column DDL for storage-less fields --- packages/core/src/schema/registry.ts | 47 +++++++++++----- packages/core/tests/fields/reference.test.ts | 59 +++++++++++++++++++- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index a98672eefc..aaad856b33 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -28,6 +28,7 @@ import { FIELD_TYPE_TO_COLUMN, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS, + STORAGELESS_FIELD_TYPES, } from "./types.js"; // Regex patterns for schema registry @@ -492,17 +493,22 @@ export class SchemaRegistry { .execute(); schemaMutated = true; - // Add column to content table — pass trx to stay on the same connection - await this.addColumn( - collectionSlug, - input.slug, - input.type, - { - required: input.required, - defaultValue: input.defaultValue, - }, - trx, - ); + // Add column to content table — pass trx to stay on the same connection. + // Storage-less field types (e.g. reference) persist no column; their + // values live in a side table (see STORAGELESS_FIELD_TYPES). Insert the + // field row only. + if (!STORAGELESS_FIELD_TYPES.has(input.type)) { + await this.addColumn( + collectionSlug, + input.slug, + input.type, + { + required: input.required, + defaultValue: input.defaultValue, + }, + trx, + ); + } // Read the created field via trx (not this.db) to avoid connection mutex deadlock const fieldRow = await trx @@ -572,6 +578,17 @@ export class SchemaRegistry { let nextType = field.type; let nextColumnType = field.columnType; if (input.type !== undefined && input.type !== field.type) { + // A change into or out of a storage-less type is never a no-op column + // change: string -> reference both map to TEXT and would slip past the + // affinity check below, yet one has a column and the other does not. + if (STORAGELESS_FIELD_TYPES.has(input.type) || STORAGELESS_FIELD_TYPES.has(field.type)) { + throw new SchemaError( + `Cannot change field "${fieldSlug}" in collection "${collectionSlug}" between ` + + `storage-less and column-backed types ("${field.type}" -> "${input.type}").`, + "FIELD_TYPE_COLUMN_CHANGE", + ); + } + const newColumnType = FIELD_TYPE_TO_COLUMN[input.type]; if (newColumnType !== field.columnType) { throw new SchemaError( @@ -740,8 +757,12 @@ export class SchemaRegistry { await this.syncSearchState(collectionSlug, trx); } - // Drop column from content table — safe now because FTS triggers are gone - await this.dropColumn(collectionSlug, fieldSlug, trx); + // Drop column from content table — safe now because FTS triggers are gone. + // Storage-less field types (e.g. reference) never had a column to begin + // with (see STORAGELESS_FIELD_TYPES), so skip the DDL. + if (!STORAGELESS_FIELD_TYPES.has(field.type)) { + await this.dropColumn(collectionSlug, fieldSlug, trx); + } }); await markContentMediaUsageCollectionStaleSafely( this.db, diff --git a/packages/core/tests/fields/reference.test.ts b/packages/core/tests/fields/reference.test.ts index 017627b7f2..16505499e6 100644 --- a/packages/core/tests/fields/reference.test.ts +++ b/packages/core/tests/fields/reference.test.ts @@ -1,7 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { sql } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { reference } from "../../src/fields/reference.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; import { STORAGELESS_FIELD_TYPES, FIELD_TYPE_TO_COLUMN } from "../../src/schema/types.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../utils/test-db.js"; describe("reference field", () => { it("should create field definition", () => { @@ -48,3 +56,52 @@ describe("storage-less field types", () => { expect(FIELD_TYPE_TO_COLUMN.reference).toBe("TEXT"); }); }); + +describeEachDialect("reference field is storage-less in the registry", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("creates the field row without adding a column, and deletes without dropping one", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + // The field row exists... + const field = await registry.getField("posts", "related"); + expect(field?.type).toBe("reference"); + + // ...but no column was added to ec_posts. (pragma_table_info is SQLite-only.) + if (dialect === "sqlite") { + const cols = await sql<{ name: string }>` + SELECT name FROM pragma_table_info('ec_posts') + `.execute(ctx.db); + expect(cols.rows.map((c) => c.name)).not.toContain("related"); + } + + // Deleting the field succeeds and drops nothing. + await expect(registry.deleteField("posts", "related")).resolves.not.toThrow(); + expect(await registry.getField("posts", "related")).toBeNull(); + }); + + it("rejects changing a field to or from reference", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("posts", { slug: "title2", label: "Title2", type: "string" }); + await expect( + registry.updateField("posts", "title2", { type: "reference" }), + ).rejects.toMatchObject({ code: "FIELD_TYPE_COLUMN_CHANGE" }); + }); +}); From 159dd3ae5f3544c92f0a23775fe879a92039e11e Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:58:39 +0300 Subject: [PATCH 03/26] feat(core): strip storage-less field keys from content data writes --- packages/core/src/api/handlers/content.ts | 38 +++++++++++++++++++ .../content/content-references-write.test.ts | 37 ++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 packages/core/tests/integration/content/content-references-write.test.ts diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 34972cf63e..f9a6f538c9 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -31,6 +31,8 @@ import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; +import { STORAGELESS_FIELD_TYPES } from "../../schema/types.js"; +import type { FieldType } from "../../schema/types.js"; import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; @@ -329,6 +331,39 @@ async function resolveSearchColumns(db: Kysely, collection: string): P return columns; } +/** + * Remove storage-less field keys (e.g. reference) from a content `data` payload + * before it reaches the column writer, which would otherwise throw "no such + * column". Defensive for direct API users; the admin sends references in the + * dedicated `references` key, not in `data`. + */ +async function stripStoragelessDataKeys( + db: Kysely, + collection: string, + data: Record, +): Promise> { + const collectionRow = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collection) + .executeTakeFirst(); + if (!collectionRow) return data; + const fields = await db + .selectFrom("_emdash_fields") + .select(["slug", "type"]) + .where("collection_id", "=", collectionRow.id) + .execute(); + const storageless = new Set( + fields.filter((f) => STORAGELESS_FIELD_TYPES.has(f.type as FieldType)).map((f) => f.slug), + ); + if (storageless.size === 0) return data; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (!storageless.has(k)) cleaned[k] = v; + } + return cleaned; +} + /** * Create a 301 auto-redirect from an entry's old URL to its new one after a * slug change, using the collection's URL pattern. Shared by @@ -659,6 +694,8 @@ export async function handleContentCreate( }; } + body.data = await stripStoragelessDataKeys(db, collection, body.data); + const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; @@ -849,6 +886,7 @@ export async function handleContentUpdate( } if (body.data) { + body.data = await stripStoragelessDataKeys(db, collection, body.data); const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; } diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts new file mode 100644 index 0000000000..d33967b230 --- /dev/null +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; + +import { handleContentCreate } from "../../../src/api/handlers/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +describeEachDialect("content write strips storage-less data keys", (dialect) => { + let ctx: DialectTestContext; + + it("does not error and does not persist a reference key placed in data", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "A", related: ["should-be-ignored"] }, + }); + + expect(res.success).toBe(true); + if (res.success) { + // The reference key must not have been written as a column value. + expect(res.data.item.data).not.toHaveProperty("related"); + } + } finally { + await teardownForDialect(ctx); + } + }); +}); From 7144b9cb81adc1f67327a1123e589e854dbf8aa0 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:08:27 +0300 Subject: [PATCH 04/26] feat(core): manifest carries reference field validation config Co-Authored-By: Claude Opus 4.8 --- packages/core/src/emdash-runtime.ts | 11 +- .../integration/manifest-reference.test.ts | 101 ++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 packages/core/tests/integration/manifest-reference.test.ts diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c5279699e0..84639552d9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2250,10 +2250,15 @@ export class EmDashRuntime { label: v.charAt(0).toUpperCase() + v.slice(1), })); } - // Include full validation for repeater fields (subFields, minItems, maxItems) - // and for file/image fields (allowedMimeTypes). + // Include full validation for repeater fields (subFields, minItems, maxItems), + // file/image fields (allowedMimeTypes), and reference fields (relation, + // targetCollection, multiple) so the admin's reference picker widget + // knows which collection(s) to relate to. if ( - (field.type === "repeater" || field.type === "file" || field.type === "image") && + (field.type === "repeater" || + field.type === "file" || + field.type === "image" || + field.type === "reference") && field.validation ) { entry.validation = { ...field.validation }; diff --git a/packages/core/tests/integration/manifest-reference.test.ts b/packages/core/tests/integration/manifest-reference.test.ts new file mode 100644 index 0000000000..047c18df39 --- /dev/null +++ b/packages/core/tests/integration/manifest-reference.test.ts @@ -0,0 +1,101 @@ +/** + * `_buildManifest` copies `field.validation` into the manifest descriptor + * for `repeater`/`file`/`image` fields, but not `reference` fields. That + * means the admin editor receives `kind: "reference"` (from + * `FIELD_TYPE_TO_KIND`) but no `relation` / `targetCollection` / `multiple` + * config to drive the reference picker widget. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import type { EmDashConfig } from "../../src/astro/integration/runtime.js"; +import type { Database } from "../../src/database/types.js"; +import { EmDashRuntime } from "../../src/emdash-runtime.js"; +import { createHookPipeline } from "../../src/plugins/hooks.js"; +import { SchemaRegistry } from "../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../utils/test-db.js"; + +function buildRuntime(db: Kysely): EmDashRuntime { + const config: EmDashConfig = {}; + const pipelineFactoryOptions = { db } as const; + const hooks = createHookPipeline([], pipelineFactoryOptions); + const pipelineRef = { current: hooks }; + const runtimeDeps = { + config, + plugins: [], + // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature + createDialect: (() => { + throw new Error("createDialect not used in this test"); + }) as any, + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; + + return new EmDashRuntime({ + db, + storage: null, + configuredPlugins: [], + sandboxedPlugins: new Map(), + sandboxedPluginEntries: [], + hooks, + enabledPlugins: new Set(), + pluginStates: new Map(), + config, + mediaProviders: new Map(), + mediaProviderEntries: [], + cronExecutor: null, + cronScheduler: null, + emailPipeline: null, + allPipelinePlugins: [], + pipelineFactoryOptions, + runtimeDeps, + pipelineRef, + }); +} + +describeEachDialect("manifest reference field validation", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("carries kind and validation for a reference field", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ + slug: "posts", + label: "Posts", + labelSingular: "Post", + source: "test", + }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }); + + const runtime = buildRuntime(ctx.db); + const manifest = await runtime.getManifest(); + + const entry = manifest.collections.posts?.fields.related; + expect(entry?.kind).toBe("reference"); + expect(entry?.validation).toMatchObject({ + relation: "grp_x", + targetCollection: "posts", + multiple: true, + }); + }); +}); From 4282dc93ff8f8dccd061aa3a5550ab5c5715e575 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:16:10 +0300 Subject: [PATCH 05/26] refactor(core): extract shared setReferenceChildren resolver Co-Authored-By: Claude Opus 4.8 --- packages/core/src/api/handlers/relations.ts | 106 ++++++++++++------ .../content/content-references-write.test.ts | 74 ++++++++++++ 2 files changed, 146 insertions(+), 34 deletions(-) diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 5640abe511..9d22478ca1 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -354,6 +354,70 @@ export async function handleReferenceChildrenGet( } } +/** + * Resolve a relation + parent entry + child ids and replace the parent's + * children under that relation. Extracted from `handleReferenceChildrenSet` so + * the content create/update transaction can reuse the same resolution logic + * (see Task 6) — `db` accepts a `Kysely` or a `Transaction` + * (assignable to `Kysely` for query building). + * + * Returns the resolved relation/entry translation_groups on success so callers + * can re-read and echo the new set without re-deriving them. + */ +export async function setReferenceChildren( + db: Kysely, + collection: string, + entryId: string, + relation: string, + childIds: string[], +): Promise> { + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const rel = await resolveRelation(repo, relation); + if (!rel) return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + if (collection !== rel.parentCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Entry is not the parent side of this relation", + }, + }; + } + + const entry = await content.findByIdOrSlug(collection, entryId); + if (!entry?.translationGroup) { + return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; + } + + // Resolve every child within the relation's child_collection in one batch + // (constant queries, not an N+1 of point lookups for a set up to 1000). A + // child id that does not resolve there fails collection-agreement + // (invariant 3); order is preserved by iterating the caller's `childIds`. + const resolvedChildren = await content.findManyByIdOrSlug(rel.childCollection, childIds); + const childGroups: string[] = []; + for (const childId of childIds) { + const child = resolvedChildren.get(childId); + if (!child?.translationGroup) { + return { + success: false, + error: { + code: "NOT_FOUND", + message: `Child entry '${childId}' not found in ${rel.childCollection}`, + }, + }; + } + childGroups.push(child.translationGroup); + } + + await repo.setChildren(rel.translationGroup, entry.translationGroup, childGroups); + return { + success: true, + data: { relationGroup: rel.translationGroup, entryGroup: entry.translationGroup }, + }; +} + export async function handleReferenceChildrenSet( db: Kysely, collection: string, @@ -362,53 +426,27 @@ export async function handleReferenceChildrenSet( childIds: string[], ): Promise> { try { + const set = await setReferenceChildren(db, collection, entryId, relation, childIds); + if (!set.success) return set; + const repo = new RelationRepository(db); const content = new ContentRepository(db); + // Re-resolve the relation/entry for their locale + childCollection — cheap + // relative to the write above, and keeps this function independent of + // `setReferenceChildren`'s internals beyond the two returned groups. const rel = await resolveRelation(repo, relation); if (!rel) return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; - if (collection !== rel.parentCollection) { - return { - success: false, - error: { - code: "VALIDATION_ERROR", - message: "Entry is not the parent side of this relation", - }, - }; - } - const entry = await content.findByIdOrSlug(collection, entryId); - if (!entry?.translationGroup) { + if (!entry) { return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } - // Resolve every child within the relation's child_collection in one batch - // (constant queries, not an N+1 of point lookups for a set up to 1000). A - // child id that does not resolve there fails collection-agreement - // (invariant 3); order is preserved by iterating the caller's `childIds`. - const resolvedChildren = await content.findManyByIdOrSlug(rel.childCollection, childIds); - const childGroups: string[] = []; - for (const childId of childIds) { - const child = resolvedChildren.get(childId); - if (!child?.translationGroup) { - return { - success: false, - error: { - code: "NOT_FOUND", - message: `Child entry '${childId}' not found in ${rel.childCollection}`, - }, - }; - } - childGroups.push(child.translationGroup); - } - - await repo.setChildren(rel.translationGroup, entry.translationGroup, childGroups); - // Return the first page of the new set, mirroring the GET shape. The actor // holds an edit permission (gated by the route), so draft children are // included in the echo. - const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup); + const edges = await repo.getChildrenPage(set.data.relationGroup, set.data.entryGroup); const children = await resolveEntries( content, rel.childCollection, diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts index d33967b230..5728d0ff54 100644 --- a/packages/core/tests/integration/content/content-references-write.test.ts +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; import { handleContentCreate } from "../../../src/api/handlers/content.js"; +import { setReferenceChildren } from "../../../src/api/handlers/relations.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; import type { DialectTestContext } from "../../utils/test-db.js"; @@ -35,3 +37,75 @@ describeEachDialect("content write strips storage-less data keys", (dialect) => } }); }); + +describeEachDialect("setReferenceChildren", (dialect) => { + let ctx: DialectTestContext; + + it("sets children on a successful call; a child outside the child collection is NOT_FOUND with no partial write", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(parent.success).toBe(true); + expect(childA.success).toBe(true); + expect(childB.success).toBe(true); + if (!parent.success || !childA.success || !childB.success) return; + + const result = await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.translationGroup, + [childA.data.item.id, childB.data.item.id], + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.relationGroup).toBe(relation.translationGroup); + + const page = await relationRepo.getChildrenPage( + result.data.relationGroup, + result.data.entryGroup, + ); + expect(page.items.map((i) => i.childGroup).toSorted()).toEqual( + [childA.data.item.id, childB.data.item.id].toSorted(), + ); + } + + // A child id outside the relation's child collection fails NOT_FOUND — + // and must not partially overwrite the set above. + const bad = await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.translationGroup, + ["nope"], + ); + expect(bad.success).toBe(false); + if (!bad.success) expect(bad.error.code).toBe("NOT_FOUND"); + + const pageAfterBad = await relationRepo.getChildrenPage( + relation.translationGroup, + parent.data.item.id, + ); + expect(pageAfterBad.items.map((i) => i.childGroup).toSorted()).toEqual( + [childA.data.item.id, childB.data.item.id].toSorted(), + ); + } finally { + await teardownForDialect(ctx); + } + }); +}); From 0dc4933e06f2c518e2b9f2c7ead9a1e9ae69ebcb Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:25:38 +0300 Subject: [PATCH 06/26] feat(core): write reference edges atomically with the content entry --- packages/core/src/api/handlers/content.ts | 54 ++++++++++++ .../content/content-references-write.test.ts | 83 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index f9a6f538c9..3f8c183d75 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -37,6 +37,7 @@ import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; +import { setReferenceChildren } from "./relations.js"; import { validateMediaFields } from "./validate-media-fields.js"; /** @@ -676,6 +677,8 @@ export async function handleContentCreate( translationOf?: string; seo?: ContentSeoInput; taxonomies?: Record; + /** Reference fields: relation translation_group → ordered child entry ids. */ + references?: Record; createdAt?: string | null; publishedAt?: string | null; }, @@ -788,6 +791,27 @@ export async function handleContentCreate( await assignTaxonomies(trx, collection, created.id, effectiveLocale, body.taxonomies); } + // Attach reference edges in the same transaction: a relation or + // child id that fails to resolve throws with a structured + // `apiError`, aborting the whole save so no half-written entry + // (with taxonomies/bylines/SEO already committed) is left behind. + if (body.references) { + for (const [relationGroup, childIds] of Object.entries(body.references)) { + const set = await setReferenceChildren( + trx, + collection, + created.id, + relationGroup, + childIds, + ); + if (!set.success) { + throw Object.assign(new Error(set.error.message), { + apiError: { code: set.error.code }, + }); + } + } + } + return created; }); @@ -796,6 +820,14 @@ export async function handleContentCreate( data: { item, _rev: encodeRev(item) }, }; } catch (error) { + // Handle structured errors thrown from inside the transaction (e.g. a + // reference resolution failure from `setReferenceChildren`). + if (hasApiError(error)) { + return { + success: false, + error: { code: error.apiError.code, message: error.message }, + }; + } if (isMissingTableError(error)) { return { success: false, @@ -868,6 +900,8 @@ export async function handleContentUpdate( _rev?: string; seo?: ContentSeoInput; taxonomies?: Record; + /** Reference fields: relation translation_group → ordered child entry ids. */ + references?: Record; publishedAt?: string | null; }, ): Promise> { @@ -989,6 +1023,26 @@ export async function handleContentUpdate( ); } + // Replace reference edges in the same transaction. See the matching + // block in handleContentCreate: a resolution failure throws with a + // structured `apiError`, aborting the whole update. + if (body.references) { + for (const [relationGroup, childIds] of Object.entries(body.references)) { + const set = await setReferenceChildren( + trx, + collection, + resolvedId, + relationGroup, + childIds, + ); + if (!set.success) { + throw Object.assign(new Error(set.error.message), { + apiError: { code: set.error.code }, + }); + } + } + } + return updated; }); diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts index 5728d0ff54..cfa18f197c 100644 --- a/packages/core/tests/integration/content/content-references-write.test.ts +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { handleContentCreate } from "../../../src/api/handlers/content.js"; import { setReferenceChildren } from "../../../src/api/handlers/relations.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RelationRepository } from "../../../src/database/repositories/relation.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; @@ -109,3 +110,85 @@ describeEachDialect("setReferenceChildren", (dialect) => { } }); }); + +describeEachDialect("content create with a `references` key", (dialect) => { + let ctx: DialectTestContext; + + it("writes reference edges atomically with the entry on create", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(childA.success).toBe(true); + expect(childB.success).toBe(true); + if (!childA.success || !childB.success) return; + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { + [relation.translationGroup]: [childA.data.item.id, childB.data.item.id], + }, + }); + expect(res.success).toBe(true); + if (!res.success) return; + + // Read back through the same edge read the REST endpoint uses — + // order must match the input array (sort_order is positional). + const page = await relationRepo.getChildrenPage(relation.translationGroup, res.data.item.id); + expect(page.items.map((i) => i.childGroup)).toEqual([ + childA.data.item.id, + childB.data.item.id, + ]); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects the whole save when a reference child is invalid", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const contentRepo = new ContentRepository(ctx.db); + const countBefore = await contentRepo.count("posts"); + + const res = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { [relation.translationGroup]: ["does-not-exist"] }, + }); + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("NOT_FOUND"); + + // The entry must NOT be persisted — a bad reference aborts the whole + // transaction, not just the reference write, so no half-written entry. + const countAfter = await contentRepo.count("posts"); + expect(countAfter).toBe(countBefore); + } finally { + await teardownForDialect(ctx); + } + }); +}); From 7f6df805980ad0f063ddb0155cef10523440265f Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:34:45 +0300 Subject: [PATCH 07/26] fix(core): expose references key in content body schemas `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. --- packages/core/src/api/schemas/content.ts | 8 ++++++ packages/core/tests/unit/api/schemas.test.ts | 26 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index fafab339b2..be3f194cb8 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -63,6 +63,10 @@ export const contentCreateBody = z description: "Taxonomy term assignments as { taxonomyName: [termSlug, ...] }, resolved in the entry's locale.", }), + references: z.record(z.string(), z.array(z.string())).optional().meta({ + description: + "Reference selections as { relationTranslationGroup: [childEntryId, ...] }, in display order. Written as content-reference edges in the same transaction as the entry.", + }), publishedAt: contentDateOverride, createdAt: contentDateOverride, }) @@ -85,6 +89,10 @@ export const contentUpdateBody = z description: "Replace taxonomy assignments as { taxonomyName: [termSlug, ...] }. Only named taxonomies are touched; pass an empty array to clear a taxonomy.", }), + references: z.record(z.string(), z.array(z.string())).optional().meta({ + description: + "Reference selections as { relationTranslationGroup: [childEntryId, ...] }, in display order. Written as content-reference edges in the same transaction as the entry.", + }), publishedAt: contentDateOverride, }) .meta({ id: "ContentUpdateBody" }); diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index a8568f0fca..560c718a5c 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -60,6 +60,19 @@ describe("contentCreateBody schema", () => { const result = contentCreateBody.parse({ data: { title: "Hi" }, publishedAt: null }); expect(result.publishedAt).toBeNull(); }); + + it("preserves references when provided", () => { + const result = contentCreateBody.parse({ + data: {}, + references: { grp_x: ["a", "b"] }, + }); + expect(result.references).toEqual({ grp_x: ["a", "b"] }); + }); + + it("accepts omitted references", () => { + const result = contentCreateBody.parse({ data: {} }); + expect(result.references).toBeUndefined(); + }); }); describe("contentUpdateBody schema", () => { @@ -119,6 +132,19 @@ describe("contentUpdateBody schema", () => { } as Parameters[0]); expect("createdAt" in result).toBe(false); }); + + it("preserves references when provided", () => { + const result = contentUpdateBody.parse({ + data: { title: "Hi" }, + references: { grp_x: ["a", "b"] }, + }); + expect(result.references).toEqual({ grp_x: ["a", "b"] }); + }); + + it("accepts omitted references", () => { + const result = contentUpdateBody.parse({ data: { title: "Hi" } }); + expect(result.references).toBeUndefined(); + }); }); describe("httpUrl validator", () => { From f8426f60cf76c8ed0627ebdbdf24f041f210d1ef Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:50:43 +0300 Subject: [PATCH 08/26] feat(core): hydrate references into the content GET response --- packages/core/src/api/handlers/content.ts | 93 +++++++++++- packages/core/src/api/handlers/relations.ts | 2 +- .../routes/api/content/[collection]/[id].ts | 4 +- packages/core/src/astro/types.ts | 1 + .../core/src/database/repositories/types.ts | 24 +++ packages/core/src/emdash-runtime.ts | 9 +- .../content/content-references-write.test.ts | 141 +++++++++++++++++- 7 files changed, 268 insertions(+), 6 deletions(-) diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 3f8c183d75..f2af9ab538 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -10,6 +10,7 @@ import type { ContentBylineInput } from "../../database/repositories/byline.js"; import { CommentRepository } from "../../database/repositories/comment.js"; import { ContentRepository } from "../../database/repositories/content.js"; import { RedirectRepository } from "../../database/repositories/redirect.js"; +import { RelationRepository } from "../../database/repositories/relation.js"; import { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; @@ -37,7 +38,7 @@ import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; -import { setReferenceChildren } from "./relations.js"; +import { resolveEntries, setReferenceChildren } from "./relations.js"; import { validateMediaFields } from "./validate-media-fields.js"; /** @@ -165,6 +166,89 @@ async function hydrateBylines( item.byline = null; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Hydrate the first page of each reference field's children onto a single + * content item. + * + * Opt-in only: callers must have already decided `includeDrafts` (draft + * visibility is enforced by the caller, not this helper) because a resolved + * child can carry a draft/scheduled entry's id and slug. See + * `handleContentGet`'s `referenceOptions` param — the REST GET route is the + * only caller that currently opts in. + * + * Reference fields are storage-less (edges live only in + * `_emdash_content_references`); a field missing `validation.relation` or + * `validation.targetCollection` is a legacy field and contributes nothing. + */ +async function hydrateReferences( + db: Kysely, + collection: string, + item: ContentItem, + includeDrafts: boolean, +): Promise { + if (!item.translationGroup) return; + + const collectionRow = await db + .selectFrom("_emdash_collections") + .select("id") + .where("slug", "=", collection) + .executeTakeFirst(); + if (!collectionRow) return; + + const fields = await db + .selectFrom("_emdash_fields") + .select("validation") + .where("collection_id", "=", collectionRow.id) + .where("type", "=", "reference") + .execute(); + + const references: NonNullable = {}; + if (fields.length === 0) { + item.references = references; + return; + } + + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + for (const field of fields) { + let validation: Record = {}; + if (field.validation) { + let parsed: unknown; + try { + parsed = JSON.parse(field.validation); + } catch { + continue; + } + if (isRecord(parsed)) validation = parsed; + } + const relationGroup = typeof validation.relation === "string" ? validation.relation : undefined; + const childCollection = + typeof validation.targetCollection === "string" ? validation.targetCollection : undefined; + if (!relationGroup || !childCollection) continue; // legacy field: no edges to hydrate + + const edges = await repo.getChildrenPage(relationGroup, item.translationGroup); + const children = await resolveEntries( + content, + childCollection, + edges.items, + (e) => e.childGroup, + item.locale, + includeDrafts, + ); + references[relationGroup] = { + children, + ...(edges.nextCursor ? { nextCursor: edges.nextCursor } : {}), + }; + } + + item.references = references; +} + /** * Batch-hydrate bylines for multiple items using two bulk queries instead of N+1. * @@ -576,6 +660,7 @@ export async function handleContentGet( collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ): Promise> { try { const repo = new ContentRepository(db); @@ -595,6 +680,12 @@ export async function handleContentGet( const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); await hydrateBylines(db, collection, item); + // Opt-in: hydration is skipped entirely unless the caller passes + // `referenceOptions`, since it can leak draft child ids/slugs — see + // `hydrateReferences`'s doc comment. + if (referenceOptions) { + await hydrateReferences(db, collection, item, referenceOptions.includeDrafts); + } return { success: true, diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 9d22478ca1..e88ff20fdd 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -263,7 +263,7 @@ function pickVariant(items: ContentItem[], locale: string | null): ContentItem | * is restricted to published entries so a draft/scheduled entry referenced by an * edge is skipped exactly like a dangling one, never leaking its id/slug/locale. */ -async function resolveEntries( +export async function resolveEntries( content: ContentRepository, collection: string, edges: ContentReference[], diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id].ts b/packages/core/src/astro/routes/api/content/[collection]/[id].ts index 16374bd636..a6535d53b7 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -28,7 +28,9 @@ export const GET: APIRoute = async ({ params, url, locals }) => { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } - const result = await emdash.handleContentGet(collection, id, locale); + const result = await emdash.handleContentGet(collection, id, locale, { + includeDrafts: hasPermission(user, "content:read_drafts"), + }); // Hide non-published items from users without content:read_drafts. Return // 404 (not 403) so subscribers can't enumerate draft IDs by status code. diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index f3cd045a5e..ec1b3cf663 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -247,6 +247,7 @@ export interface EmDashHandlers { collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ) => Promise< HandlerResponse<{ item: { diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index a0a315c17c..89e214ce4e 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -258,6 +258,30 @@ export interface ContentItem { * revision history. */ liveData?: Record; + /** + * First page of resolved children per reference field, keyed by the + * field's relation group. Only populated when the caller opts in via + * `handleContentGet`'s `referenceOptions` param (see content.ts) — + * hydration is never unconditional because it can leak draft child + * ids/slugs to callers without `content:read_drafts`. + * + * Shape mirrors `EntryRef` from `api/handlers/relations.ts`, duplicated + * here (rather than imported) so the database layer doesn't depend on + * the api/handlers layer. + */ + references?: Record< + string, + { + children: Array<{ + id: string; + slug: string | null; + collection: string; + locale: string | null; + sortOrder?: number; + }>; + nextCursor?: string; + } + >; } export class EmDashValidationError extends Error { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 84639552d9..0e209f2666 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2529,8 +2529,13 @@ export class EmDashRuntime { return handleContentAuthors(this.db, collection); } - async handleContentGet(collection: string, id: string, locale?: string) { - const result = await handleContentGet(this.db, collection, id, locale); + async handleContentGet( + collection: string, + id: string, + locale?: string, + referenceOptions?: { includeDrafts: boolean }, + ) { + const result = await handleContentGet(this.db, collection, id, locale, referenceOptions); return this.hydrateDraftData(result); } diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts index cfa18f197c..7f9a26c116 100644 --- a/packages/core/tests/integration/content/content-references-write.test.ts +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; -import { handleContentCreate } from "../../../src/api/handlers/content.js"; +import { handleContentCreate, handleContentGet } from "../../../src/api/handlers/content.js"; import { setReferenceChildren } from "../../../src/api/handlers/relations.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RelationRepository } from "../../../src/database/repositories/relation.js"; @@ -192,3 +192,142 @@ describeEachDialect("content create with a `references` key", (dialect) => { } }); }); + +describeEachDialect("handleContentGet reference hydration (opt-in)", (dialect) => { + let ctx: DialectTestContext; + + it("hydrates the first page of references when referenceOptions is passed", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + // The reference field must carry validation.relation + targetCollection + // so hydration can discover it and its child collection. + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { + relation: relation.translationGroup, + targetCollection: "posts", + multiple: true, + }, + }); + + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(childA.success && childB.success).toBe(true); + if (!childA.success || !childB.success) return; + + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { + [relation.translationGroup]: [childA.data.item.id, childB.data.item.id], + }, + }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id, undefined, { + includeDrafts: true, + }); + expect(got.success).toBe(true); + if (got.success) { + const refs = got.data.item.references?.[relation.translationGroup]; + expect(refs?.children.map((c) => c.id)).toEqual([childA.data.item.id, childB.data.item.id]); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("hydrates nothing for a legacy reference field with no validation.relation", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + // Legacy reference field: validation without `relation`/`targetCollection`. + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { multiple: true }, + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id, undefined, { + includeDrafts: true, + }); + expect(got.success).toBe(true); + if (got.success) { + // No crash; the legacy field contributes no reference group. + expect(got.data.item.references).toEqual({}); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("does not hydrate references when referenceOptions is omitted (opt-in)", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + await registry.createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { + relation: relation.translationGroup, + targetCollection: "posts", + multiple: true, + }, + }); + + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(child.success).toBe(true); + if (!child.success) return; + + const parent = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent" }, + references: { [relation.translationGroup]: [child.data.item.id] }, + }); + expect(parent.success).toBe(true); + if (!parent.success) return; + + // Omit the 5th arg → no hydration, no extra queries. + const got = await handleContentGet(ctx.db, "posts", parent.data.item.id); + expect(got.success).toBe(true); + if (got.success) { + expect(got.data.item.references).toBeUndefined(); + } + } finally { + await teardownForDialect(ctx); + } + }); +}); From 6049e0d1c487bdefca7eae4b9643b483367865fc Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:12:48 +0300 Subject: [PATCH 09/26] feat(core): schema handlers own the reference field relation lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/api/handlers/schema.ts | 163 +++++++++++- packages/core/src/database/transaction.ts | 13 + .../schema/reference-field-lifecycle.test.ts | 243 ++++++++++++++++++ 3 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 packages/core/tests/integration/schema/reference-field-lifecycle.test.ts diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 2426a47d39..0f71744333 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -4,6 +4,8 @@ import type { Kysely } from "kysely"; +import { RelationRepository, type Relation } from "../../database/repositories/relation.js"; +import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; import { invalidateCollectionCache } from "../../object-cache/index.js"; import { @@ -19,6 +21,58 @@ import { } from "../../schema/index.js"; import type { ApiResult } from "../types.js"; +/** Maximum attempts to allocate a unique relation name for a new reference + * field: the base `${collection}_${field}` name, then `_2` through `_5`. */ +const RELATION_NAME_MAX_ATTEMPTS = 5; + +/** True for SQLite UNIQUE / Postgres unique_violation messages — mirrors the + * fingerprint used in the relations API handler. */ +function isUniqueViolation(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return message.includes("unique") || message.includes("duplicate"); +} + +/** + * Create the relation definition backing a new reference field, retrying + * with a numeric suffix on a name collision. Runs inside the caller's + * transaction so the relation and the field row it backs commit or roll + * back together. + */ +async function createFieldRelation( + trx: Kysely, + collectionSlug: string, + fieldSlug: string, + fieldLabel: string, + targetCollection: string, +): Promise { + const registry = new SchemaRegistry(trx); + const relations = new RelationRepository(trx); + + const parent = await registry.getCollection(collectionSlug); + if (!parent) { + throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); + } + + const baseName = `${collectionSlug}_${fieldSlug}`.slice(0, 63); + for (let attempt = 0; attempt < RELATION_NAME_MAX_ATTEMPTS; attempt++) { + const suffix = attempt === 0 ? "" : `_${attempt + 1}`; + const name = attempt === 0 ? baseName : `${baseName.slice(0, 63 - suffix.length)}${suffix}`; + try { + return await relations.create({ + name, + parentCollection: collectionSlug, + childCollection: targetCollection, + parentLabel: parent.labelSingular ?? parent.label, + childLabel: fieldLabel, + }); + } catch (error) { + const isLastAttempt = attempt === RELATION_NAME_MAX_ATTEMPTS - 1; + if (isLastAttempt || !isUniqueViolation(error)) throw error; + } + } + throw new SchemaError("Could not allocate a unique relation name", "RELATION_NAME_CONFLICT"); +} + export interface CollectionListResponse { items: Collection[]; } @@ -313,6 +367,49 @@ export async function handleSchemaFieldCreate( input: CreateFieldInput, ): Promise> { try { + if (input.type === "reference") { + const targetCollection = input.validation?.targetCollection; + if (!targetCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Reference field requires a target collection", + }, + }; + } + + // The relation def and the field row it backs must commit or roll + // back together — a field without its relation (or vice versa) is + // an inconsistent reference field. + const item = await withTransaction(db, async (trx) => { + const relation = await createFieldRelation( + trx, + collectionSlug, + input.slug, + input.label, + targetCollection, + ); + const registry = new SchemaRegistry(trx); + return registry.createField(collectionSlug, { + ...input, + validation: { + ...input.validation, + relation: relation.translationGroup, + targetCollection, + }, + }); + }); + + // Content snapshots embed field values; a column change invalidates them. + invalidateCollectionCache(collectionSlug); + + return { + success: true, + data: { item }, + }; + } + const registry = new SchemaRegistry(db); const item = await registry.createField(collectionSlug, input); @@ -354,6 +451,50 @@ export async function handleSchemaFieldUpdate( input: UpdateFieldInput, ): Promise> { try { + const lookupRegistry = new SchemaRegistry(db); + const existing = await lookupRegistry.getField(collectionSlug, fieldSlug); + const relationGroup = + existing?.type === "reference" ? existing.validation?.relation : undefined; + + if (existing && relationGroup) { + // The relation's childCollection is immutable — a reference field's + // target collection can't change after the relation is wired up. + const nextTargetCollection = input.validation?.targetCollection; + if ( + nextTargetCollection !== undefined && + nextTargetCollection !== existing.validation?.targetCollection + ) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Cannot change the target collection of an existing reference field", + }, + }; + } + + const item = await withTransaction(db, async (trx) => { + const registry = new SchemaRegistry(trx); + const updated = await registry.updateField(collectionSlug, fieldSlug, input); + + if (input.label !== undefined && input.label !== existing.label) { + const relations = new RelationRepository(trx); + const siblings = await relations.findTranslations(relationGroup); + const target = siblings[0]; + if (target) await relations.update(target.id, { childLabel: input.label }); + } + + return updated; + }); + + invalidateCollectionCache(collectionSlug); + + return { + success: true, + data: { item }, + }; + } + const registry = new SchemaRegistry(db); const item = await registry.updateField(collectionSlug, fieldSlug, input); @@ -393,8 +534,26 @@ export async function handleSchemaFieldDelete( fieldSlug: string, ): Promise> { try { - const registry = new SchemaRegistry(db); - await registry.deleteField(collectionSlug, fieldSlug); + const lookupRegistry = new SchemaRegistry(db); + const existing = await lookupRegistry.getField(collectionSlug, fieldSlug); + const relationGroup = + existing?.type === "reference" ? existing.validation?.relation : undefined; + + if (relationGroup) { + // The field row and the relation def (plus its edges) it backs must + // go together — a reference field can't outlive its relation, and a + // relation left behind after its field is gone is an orphan. + await withTransaction(db, async (trx) => { + const registry = new SchemaRegistry(trx); + const relations = new RelationRepository(trx); + await registry.deleteField(collectionSlug, fieldSlug); + const siblings = await relations.findTranslations(relationGroup); + for (const sibling of siblings) await relations.delete(sibling.id); + }); + } else { + const registry = new SchemaRegistry(db); + await registry.deleteField(collectionSlug, fieldSlug); + } invalidateCollectionCache(collectionSlug); diff --git a/packages/core/src/database/transaction.ts b/packages/core/src/database/transaction.ts index 238d586dcc..b91879b0aa 100644 --- a/packages/core/src/database/transaction.ts +++ b/packages/core/src/database/transaction.ts @@ -29,6 +29,19 @@ export async function withTransaction( db: Kysely, fn: (trx: Kysely | Transaction) => Promise, ): Promise { + // Nested call: `db` is already a transaction. Kysely rejects calling + // `.transaction()` on a `Transaction` outright (a hard error, not the + // "transactions are not supported" message the probe below expects), so a + // naive nested `withTransaction(trx, ...)` call would always throw on any + // dialect that supports real transactions. Running `fn` directly against + // the existing transaction makes the nested work part of the enclosing + // one — exactly what nested callers (e.g. a handler composing two + // repositories that each self-wrap in `withTransaction`) want: the whole + // chain commits or rolls back together. + if (db.isTransaction) { + return fn(db); + } + // Fast path: we already know transactions work if (transactionsSupported === true) { return db.transaction().execute(fn); diff --git a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts new file mode 100644 index 0000000000..3c07bbe677 --- /dev/null +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -0,0 +1,243 @@ +import { expect, it } from "vitest"; + +import { + handleSchemaFieldCreate, + handleSchemaFieldDelete, + handleSchemaFieldUpdate, +} from "../../../src/api/handlers/schema.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +describeEachDialect("reference field lifecycle", (dialect) => { + let ctx: DialectTestContext; + + it("creates a relation def when a reference field is created and stores its group on the field", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + + expect(res.success).toBe(true); + + const repo = new RelationRepository(ctx.db); + const relations = await repo.list(); + const rel = relations.find((r) => r.name === "posts_related"); + expect(rel).toBeTruthy(); + expect(rel?.parentCollection).toBe("posts"); + expect(rel?.childCollection).toBe("posts"); + if (res.success) { + expect(res.data.item.validation?.relation).toBe(rel?.translationGroup); + expect(res.data.item.validation?.targetCollection).toBe("posts"); + } + } finally { + await teardownForDialect(ctx); + } + }); + + it("deletes the relation and its edges when the reference field is deleted", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationGroup = created.data.item.validation?.relation; + expect(relationGroup).toBeTruthy(); + if (!relationGroup) return; + + // Seed an edge under the relation so we can assert it's purged too. + const relRepo = new RelationRepository(ctx.db); + await relRepo.addReference(relationGroup, "parent-group-x", "child-group-y"); + const edgesBefore = await ctx.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("relation_group", "=", relationGroup) + .execute(); + expect(edgesBefore.length).toBe(1); + + const del = await handleSchemaFieldDelete(ctx.db, "posts", "related"); + expect(del.success).toBe(true); + + const relations = await relRepo.list(); + expect(relations.find((r) => r.name === "posts_related")).toBeUndefined(); + + const edgesAfter = await ctx.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("relation_group", "=", relationGroup) + .execute(); + expect(edgesAfter.length).toBe(0); + + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects creating a reference field with no target collection", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { multiple: true }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("VALIDATION_ERROR"); + + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("PATCHes the relation's childLabel when the field's label is updated", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationGroup = created.data.item.validation?.relation; + expect(relationGroup).toBeTruthy(); + if (!relationGroup) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + label: "Related posts", + }); + expect(updated.success).toBe(true); + + const relRepo = new RelationRepository(ctx.db); + const siblings = await relRepo.findTranslations(relationGroup); + expect(siblings[0]?.childLabel).toBe("Related posts"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rejects changing the target collection of an existing reference field", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + validation: { targetCollection: "pages", multiple: true }, + }); + expect(updated.success).toBe(false); + if (!updated.success) expect(updated.error.code).toBe("VALIDATION_ERROR"); + + // The stored field must be unaffected by the rejected update. + const field = await registry.getField("posts", "related"); + expect(field?.validation?.targetCollection).toBe("posts"); + } finally { + await teardownForDialect(ctx); + } + }); + + it("leaves no orphan field row when the relation name cannot be allocated", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + // Occupy every name the suffix-retry loop would try (base + _2.._5) + // so relation allocation is forced to exhaust and fail. + const relRepo = new RelationRepository(ctx.db); + const names = [ + "posts_related", + "posts_related_2", + "posts_related_3", + "posts_related_4", + "posts_related_5", + ]; + for (const name of names) { + await relRepo.create({ + name, + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Posts", + childLabel: "Occupied", + }); + } + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(res.success).toBe(false); + + // No orphan field row from the failed attempt. + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("rolls back the just-created relation when field creation fails after it (atomicity)", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + // "id" is a reserved field slug — registry.createField rejects it + // *after* the relation for this attempt has already been created, + // exercising rollback of the relation insert alongside the field. + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "id", + label: "Id", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(res.success).toBe(false); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.name === "posts_id")).toBeUndefined(); + } finally { + await teardownForDialect(ctx); + } + }); +}); From 26eeabac248070bba8572e8bd007d7bc5ba4a65f Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:38:30 +0300 Subject: [PATCH 10/26] fix(core): keep reference field relation link immutable on schema update --- packages/core/src/api/handlers/schema.ts | 19 ++++- .../schema/reference-field-lifecycle.test.ts | 72 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 0f71744333..9f13324689 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -473,9 +473,26 @@ export async function handleSchemaFieldUpdate( }; } + // `relation` and `targetCollection` are immutable identity for a wired + // reference field. An update that sends `validation: null` or a partial + // validation object omitting these keys must not be allowed to clear + // them -- registry.updateField() writes whatever is passed verbatim, + // which would otherwise orphan the relation row and its edges. + const updateInput = + input.validation !== undefined + ? { + ...input, + validation: { + ...input.validation, + relation: existing.validation?.relation, + targetCollection: existing.validation?.targetCollection, + }, + } + : input; + const item = await withTransaction(db, async (trx) => { const registry = new SchemaRegistry(trx); - const updated = await registry.updateField(collectionSlug, fieldSlug, input); + const updated = await registry.updateField(collectionSlug, fieldSlug, updateInput); if (input.label !== undefined && input.label !== existing.label) { const relations = new RelationRepository(trx); diff --git a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts index 3c07bbe677..b977861d83 100644 --- a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -145,6 +145,78 @@ describeEachDialect("reference field lifecycle", (dialect) => { } }); + it("preserves relation and targetCollection when validation is explicitly null", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationGroup = created.data.item.validation?.relation; + expect(relationGroup).toBeTruthy(); + if (!relationGroup) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + label: "Related posts", + validation: null, + }); + expect(updated.success).toBe(true); + + const field = await registry.getField("posts", "related"); + expect(field?.validation?.relation).toBe(relationGroup); + expect(field?.validation?.targetCollection).toBe("posts"); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.name === "posts_related")).toBeTruthy(); + } finally { + await teardownForDialect(ctx); + } + }); + + it("preserves relation and targetCollection when validation omits them", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationGroup = created.data.item.validation?.relation; + expect(relationGroup).toBeTruthy(); + if (!relationGroup) return; + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + validation: { multiple: false }, + }); + expect(updated.success).toBe(true); + + const field = await registry.getField("posts", "related"); + expect(field?.validation?.relation).toBe(relationGroup); + expect(field?.validation?.targetCollection).toBe("posts"); + expect(field?.validation?.multiple).toBe(false); + + const relRepo = new RelationRepository(ctx.db); + const relations = await relRepo.list(); + expect(relations.find((r) => r.name === "posts_related")).toBeTruthy(); + } finally { + await teardownForDialect(ctx); + } + }); + it("rejects changing the target collection of an existing reference field", async () => { ctx = await setupForDialect(dialect); try { From e3be4f526e98999211c67a0982d93bda4e6d2f64 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:46:18 +0300 Subject: [PATCH 11/26] feat(admin): relations read API client --- packages/admin/src/lib/api/index.ts | 10 +++ packages/admin/src/lib/api/relations.ts | 88 +++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/admin/src/lib/api/relations.ts diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index f4eb905825..7b71ae0547 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -390,3 +390,13 @@ export { // Current user export { type CurrentUser, useCurrentUser } from "./current-user.js"; + +// Relations (reference fields) +export { + type RelationDef, + type EntryRef, + type ReferencePageOptions, + fetchRelations, + fetchReferenceChildren, + fetchReferenceParents, +} from "./relations.js"; diff --git a/packages/admin/src/lib/api/relations.ts b/packages/admin/src/lib/api/relations.ts new file mode 100644 index 0000000000..2aac9b5976 --- /dev/null +++ b/packages/admin/src/lib/api/relations.ts @@ -0,0 +1,88 @@ +/** + * Relations API (reference field definitions and edges). + */ + +import { API_BASE, apiFetch, parseApiResponse } from "./client.js"; + +export interface RelationDef { + id: string; + name: string; + parentCollection: string; + childCollection: string; + parentLabel: string; + childLabel: string; + locale: string; + translationGroup: string; +} + +export interface EntryRef { + id: string; + slug: string | null; + collection: string; + locale: string | null; + sortOrder?: number; +} + +export interface ReferencePageOptions { + cursor?: string; + limit?: number; +} + +/** + * Fetch all relation definitions. + */ +export async function fetchRelations(locale?: string): Promise { + const qs = locale ? `?locale=${encodeURIComponent(locale)}` : ""; + const response = await apiFetch(`${API_BASE}/relations${qs}`); + const data = await parseApiResponse<{ relations: RelationDef[] }>( + response, + "Failed to fetch relations", + ); + return data.relations; +} + +function buildPageQuery(opts: ReferencePageOptions = {}): string { + const params = new URLSearchParams(); + if (opts.cursor) params.set("cursor", opts.cursor); + if (opts.limit) params.set("limit", String(opts.limit)); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Fetch the children of an entry for a given relation (parent side). + */ +export async function fetchReferenceChildren( + collection: string, + id: string, + relation: string, + opts: ReferencePageOptions = {}, +): Promise<{ children: EntryRef[]; nextCursor?: string }> { + const qs = buildPageQuery(opts); + const response = await apiFetch( + `${API_BASE}/content/${collection}/${id}/references/${relation}/children${qs}`, + ); + return parseApiResponse<{ children: EntryRef[]; nextCursor?: string }>( + response, + "Failed to fetch reference children", + ); +} + +/** + * Fetch the parents of an entry for a given relation (child side). + */ +export async function fetchReferenceParents( + collection: string, + id: string, + relation: string, + opts: ReferencePageOptions = {}, +): Promise<{ parents: EntryRef[]; nextCursor?: string }> { + const qs = buildPageQuery(opts); + const response = await apiFetch( + `${API_BASE}/content/${collection}/${id}/references/${relation}/parents${qs}`, + ); + return parseApiResponse<{ parents: EntryRef[]; nextCursor?: string }>( + response, + "Failed to fetch reference parents", + ); +} From 706f9c345f3dc0757533bb1e3941248338365273 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:57:11 +0300 Subject: [PATCH 12/26] feat(admin): reference field config step in the schema editor --- packages/admin/src/components/FieldEditor.tsx | 54 +++++++++++++++++++ packages/admin/src/lib/api/schema.ts | 9 ++++ 2 files changed, 63 insertions(+) diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 435ea9dddb..571efcae3c 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -20,8 +20,10 @@ import { Trash, X, } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import * as React from "react"; +import { fetchCollections } from "../lib/api"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; import { cn } from "../lib/utils"; import { AllowedTypesEditor } from "./AllowedTypesEditor"; @@ -77,6 +79,8 @@ interface FieldFormState { minItems: string; maxItems: string; allowedMimeTypes: string[]; + targetCollection: string; + allowMultiple: boolean; } function getInitialFormState(field?: SchemaField): FieldFormState { @@ -101,6 +105,8 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], + targetCollection: field.validation?.targetCollection ?? "", + allowMultiple: field.validation?.multiple ?? true, }; } return { @@ -121,6 +127,8 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: "", maxItems: "", allowedMimeTypes: [], + targetCollection: "", + allowMultiple: true, }; } @@ -130,16 +138,24 @@ function getInitialFormState(field?: SchemaField): FieldFormState { export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) { const { t } = useLingui(); const [formState, setFormState] = React.useState(() => getInitialFormState(field)); + const [refError, setRefError] = React.useState(false); + + const { data: collections = [] } = useQuery({ + queryKey: ["collections"], + queryFn: fetchCollections, + }); // Reset state when dialog opens React.useEffect(() => { if (open) { setFormState(getInitialFormState(field)); + setRefError(false); } }, [open, field]); const { step, selectedType, slug, label, required, unique, searchable } = formState; const { minLength, maxLength, min, max, pattern, options } = formState; + const { targetCollection, allowMultiple } = formState; const setField = (key: K, value: FieldFormState[K]) => setFormState((prev) => ({ ...prev, [key]: value })); @@ -265,6 +281,11 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie const handleSave = () => { if (!selectedType || !slug || !label) return; + if (selectedType === "reference" && !targetCollection) { + setRefError(true); + return; + } + const validation: CreateFieldInput["validation"] = {}; // Build validation based on field type @@ -311,6 +332,11 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie validation.allowedMimeTypes = formState.allowedMimeTypes; } + if (selectedType === "reference") { + validation.targetCollection = targetCollection; + validation.multiple = allowMultiple; + } + // Only include searchable for text-based fields const isSearchableType = selectedType === "string" || @@ -517,6 +543,34 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie /> )} + {selectedType === "reference" && ( +
+

{t`Reference`}

+ setSearchQuery(e.target.value)} + className="ps-10" + autoFocus + /> +
+ + + {/* Content list */} +
+ {contentLoading ? ( +
+
{t`Loading content...`}
+
+ ) : allItems.length === 0 ? ( +
+ {debouncedSearch ? ( + <> + +

{t`No content found`}

+

{t`Try adjusting your search`}

+ + ) : ( + <> + +

{t`No content in this collection`}

+ + )} +
+ ) : ( +
+ {allItems.map((item) => { + const status = getDraftStatus(item); + const alreadyLinked = selectedIds.has(item.id); + const isPicked = alreadyLinked || !!picked[item.id]; + const statusDot = ( + + ); + const statusLabel = + status === "published" + ? t`Published` + : status === "published_with_changes" + ? t`Modified` + : t`Draft`; + const meta = ( +
+ {statusDot} + {statusLabel} + {item.slug && ( + <> + / + {item.slug} + + )} +
+ ); + + if (multiple) { + return ( + + ); + } + + return ( + + ); + })} + {nextCursor && ( +
+ +
+ )} +
+ )} +
+ + {/* Footer */} +
+ + {multiple && ( + + )} +
+ + + ); +} diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 23030c2e97..c26ed96ba8 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -13,6 +13,7 @@ import { throwResponseError, type FindManyResult, } from "./client.js"; +import type { EntryRef } from "./relations.js"; /** * Derive draft status from a content item's revision pointers @@ -58,6 +59,13 @@ export interface ContentItem { liveRevisionId: string | null; draftRevisionId: string | null; seo?: ContentSeo; + /** + * First page of reference-field edges, keyed by relation translation group. + * Only present when the server opts into hydration (the editor GET route). + * Each field's entries are stored solely in `_emdash_content_references`; + * the admin sends the desired id lists back in the `references` save key. + */ + references?: Record; } export interface CreateContentInput { @@ -68,6 +76,8 @@ export interface CreateContentInput { bylines?: BylineCreditInput[]; locale?: string; translationOf?: string; + /** Reference-field edges to write atomically, keyed by relation group. */ + references?: Record; } export interface TranslationSummary { @@ -112,6 +122,8 @@ export interface UpdateContentInput { /** Skip revision creation (used by autosave) */ skipRevision?: boolean; seo?: ContentSeoInput; + /** Reference-field edges to replace atomically, keyed by relation group. */ + references?: Record; } /** @@ -235,6 +247,7 @@ export async function createContent( bylines: input.bylines, locale: input.locale, translationOf: input.translationOf, + references: input.references, }), }); const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to create content"); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index d0006f7f2c..ea3197630e 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -628,6 +628,7 @@ function ContentNewPage() { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => createContent(collection, { ...data, locale: pickerLocale }), onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: ["content", collection] }); @@ -685,6 +686,7 @@ function ContentNewPage() { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => { createMutation.mutate(payload); }; @@ -870,6 +872,7 @@ function ContentEditPage() { bylines?: BylineCreditInput[]; skipRevision?: boolean; seo?: ContentSeoInput; + references?: Record; }) => updateContent(collection, id, data, { locale: rawItem?.locale ?? activeLocale }), onSuccess: () => { // Invalidate by (collection, id) prefix without the locale object: the @@ -905,6 +908,7 @@ function ContentEditPage() { data?: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => updateContent( collection, @@ -1106,6 +1110,7 @@ function ContentEditPage() { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => { updateMutation.mutate(payload); }; @@ -1114,6 +1119,7 @@ function ContentEditPage() { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => { autosaveMutation.mutate(payload); }; From 563c56fb90689350b6f474e3ca3d1d619604081b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:04:35 +0300 Subject: [PATCH 14/26] feat(admin): referenced-by backlinks sidebar --- .../admin/src/components/ContentEditor.tsx | 12 ++ .../src/components/ReferencesSidebar.tsx | 195 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/admin/src/components/ReferencesSidebar.tsx diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index a407d32f80..b9e52df142 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -52,6 +52,7 @@ import { DocumentOutline } from "./editor/DocumentOutline"; import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js"; import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js"; import { ReferencePickerModal, type ReferencePickerRow } from "./ReferencePickerModal.js"; +import { ReferencesSidebar } from "./ReferencesSidebar.js"; import { RepeaterField } from "./RepeaterField.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; @@ -1249,6 +1250,17 @@ export function ContentEditor({ )} + {/* Referenced by — read-only backlinks for existing entries */} + {item && !isNew && ( +
+ +
+ )} + {/* SEO panel - shown for collections with hasSeo enabled */} {hasSeo && !isNew && onSeoChange && (
diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx new file mode 100644 index 0000000000..2fffed6504 --- /dev/null +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -0,0 +1,195 @@ +/** + * References Sidebar for Content Editor + * + * Read-only "Referenced by" panel shown in the content editor sidebar for + * existing entries. For each relation whose child side is the entry's + * collection, it lists the parent entries that reference the entry being + * edited (the reverse direction of the parent-side reference field renderer). + * + * The panel renders nothing when there are no applicable relations, when the + * relations read fails (e.g. the viewer lacks `schema:read`, a 403), or when + * every applicable relation has an empty parents list — so collections that + * nobody references stay out of the way rather than showing an empty state. + */ + +import { Button, Loader } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import { + type EntryRef, + type RelationDef, + fetchReferenceParents, + fetchRelations, +} from "../lib/api/relations.js"; + +interface ReferencesSidebarProps { + collection: string; + entryId: string; + /** Locale of the entry being edited. Scopes the relation definitions read so + * labels localize to the entry's translation. */ + entryLocale?: string; +} + +interface ParentsState { + items: EntryRef[]; + nextCursor?: string; + loading: boolean; +} + +const PAGE_SIZE = 50; + +export function ReferencesSidebar({ collection, entryId, entryLocale }: ReferencesSidebarProps) { + const { t } = useLingui(); + const relationsQuery = useQuery({ + queryKey: ["relations", entryLocale ?? null], + queryFn: () => fetchRelations(entryLocale), + // A 403 means the viewer genuinely lacks `schema:read`; retrying just adds + // latency before we hide the panel, which is the desired outcome. + retry: false, + }); + + const applicableRelations = React.useMemo( + () => (relationsQuery.data ?? []).filter((r) => r.childCollection === collection), + [relationsQuery.data, collection], + ); + + // Per-relation parents pagination, keyed by relation id. First pages are + // loaded on demand (effect below) once the relations list resolves; manual + // "Load more" advances the cursor for an individual relation. + const [parentsByRel, setParentsByRel] = React.useState>({}); + + // Dump stale state and load the first page for each applicable relation. + // Re-runs when the entry, collection, or applicable relation set changes. + React.useEffect(() => { + let cancelled = false; + // New entry/relation slice — start from a clean slate so cursor keys from + // a previously displayed entry never bleed through. + setParentsByRel({}); + void (async () => { + for (const rel of applicableRelations) { + try { + const res = await fetchReferenceParents(collection, entryId, rel.name, { + limit: PAGE_SIZE, + }); + if (cancelled) return; + setParentsByRel((prev) => ({ + ...prev, + [rel.id]: { items: res.parents, nextCursor: res.nextCursor, loading: false }, + })); + } catch { + if (cancelled) return; + // A single relation failing (not found, draft-read denied, …) + // shouldn't crash the panel — record it as empty so the heading + // still hides once every relation has resolved. + setParentsByRel((prev) => ({ + ...prev, + [rel.id]: { items: [], nextCursor: undefined, loading: false }, + })); + } + } + })(); + return () => { + cancelled = true; + }; + }, [applicableRelations, collection, entryId]); + + const loadMore = React.useCallback( + async (rel: RelationDef) => { + // Read current cursor synchronously from state to avoid a stale closure + // when multiple "Load more" clicks queue up. + let cursor: string | undefined; + setParentsByRel((prev) => { + const cur = prev[rel.id]; + if (!cur || !cur.nextCursor || cur.loading) return prev; + cursor = cur.nextCursor; + return { ...prev, [rel.id]: { ...cur, loading: true } }; + }); + if (!cursor) return; + try { + const res = await fetchReferenceParents(collection, entryId, rel.name, { + cursor, + limit: PAGE_SIZE, + }); + setParentsByRel((prev) => { + const cur = prev[rel.id]; + if (!cur) return prev; + const seen = new Set(cur.items.map((p) => p.id)); + const merged = [...cur.items, ...res.parents.filter((p) => !seen.has(p.id))]; + return { + ...prev, + [rel.id]: { items: merged, nextCursor: res.nextCursor, loading: false }, + }; + }); + } catch { + setParentsByRel((prev) => { + const cur = prev[rel.id]; + return cur ? { ...prev, [rel.id]: { ...cur, loading: false } } : prev; + }); + } + }, + [collection, entryId], + ); + + // RENDER GATES (after all hooks) — the panel is optional context. While the + // relations list is still loading or has errored, stay out of the layout. + if (relationsQuery.isLoading || relationsQuery.error) return null; + if (applicableRelations.length === 0) return null; + + const isPopulated = (rel: RelationDef): boolean => (parentsByRel[rel.id]?.items.length ?? 0) > 0; + if (!applicableRelations.some(isPopulated)) return null; + const populatedRels = applicableRelations.filter(isPopulated); + + return ( +
+

{t`Referenced by`}

+
+ {populatedRels.map((rel) => { + const state = parentsByRel[rel.id]; + // `state` is guarded by `isPopulated` above, but the TS narrowing + // across the .filter callback doesn't carry through. + if (!state) return null; + return ( +
+

{rel.parentLabel}

+
    + {state.items.map((parent) => ( +
  • + + {parent.slug ?? parent.id} + +
  • + ))} +
+ {state.nextCursor && ( +
+ +
+ )} +
+ ); + })} +
+
+ ); +} From b8c46668dac392123b0c5485c8d7967ac08a4cf3 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:45:31 +0300 Subject: [PATCH 15/26] fix(core): drop unsafe FieldType cast in storage-less key strip Type STORAGELESS_FIELD_TYPES as ReadonlySet 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 --- packages/core/src/api/handlers/content.ts | 3 +-- packages/core/src/schema/types.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index f2af9ab538..7dd0718382 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -33,7 +33,6 @@ import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; import { STORAGELESS_FIELD_TYPES } from "../../schema/types.js"; -import type { FieldType } from "../../schema/types.js"; import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; @@ -439,7 +438,7 @@ async function stripStoragelessDataKeys( .where("collection_id", "=", collectionRow.id) .execute(); const storageless = new Set( - fields.filter((f) => STORAGELESS_FIELD_TYPES.has(f.type as FieldType)).map((f) => f.slug), + fields.filter((f) => STORAGELESS_FIELD_TYPES.has(f.type)).map((f) => f.slug), ); if (storageless.size === 0) return data; const cleaned: Record = {}; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 7de23db141..f2bf3e1886 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -81,7 +81,7 @@ export const FIELD_TYPE_TO_COLUMN: Record = { * never a column on the content table. The `FIELD_TYPE_TO_COLUMN` entry above is * retained deliberately: it doubles as the `isFieldType` guard. */ -export const STORAGELESS_FIELD_TYPES: ReadonlySet = new Set(["reference"]); +export const STORAGELESS_FIELD_TYPES: ReadonlySet = new Set(["reference"]); /** * Features a collection can support From 6537154f40046a01d815bfef14f0fd5d6329651d Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:16:41 +0300 Subject: [PATCH 16/26] fix(core): apply reference fields as edges in the seed engine 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 --- packages/core/src/api/handlers/schema.ts | 2 +- packages/core/src/seed/apply.ts | 223 ++++++++++++++++---- packages/core/tests/unit/seed/apply.test.ts | 49 +++-- 3 files changed, 214 insertions(+), 60 deletions(-) diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 9f13324689..52a27b7d2d 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -38,7 +38,7 @@ function isUniqueViolation(error: unknown): boolean { * transaction so the relation and the field row it backs commit or roll * back together. */ -async function createFieldRelation( +export async function createFieldRelation( trx: Kysely, collectionSlug: string, fieldSlug: string, diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 091c7e2500..7cfd7b54f9 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -10,6 +10,8 @@ import type { Kysely } from "kysely"; import mime from "mime/lite"; import { ulid } from "ulidx"; +import { setReferenceChildren } from "../api/handlers/relations.js"; +import { createFieldRelation } from "../api/handlers/schema.js"; import { BylineRepository } from "../database/repositories/byline.js"; import { ContentRepository } from "../database/repositories/content.js"; import { MediaRepository } from "../database/repositories/media.js"; @@ -23,11 +25,13 @@ import { getI18nConfig } from "../i18n/config.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js"; import { SchemaRegistry } from "../schema/registry.js"; +import type { Field } from "../schema/types.js"; import { FTSManager } from "../search/fts-manager.js"; import { setSiteSettings } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import type { SeedFile, + SeedField, SeedApplyOptions, SeedApplyResult, SeedTaxonomyTerm, @@ -186,34 +190,9 @@ export async function applySeed( // Update or create fields for (const field of collection.fields) { const existingField = await registry.getField(collection.slug, field.slug); - if (existingField) { - await registry.updateField(collection.slug, field.slug, { - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - }); - result.fields.updated++; - } else { - await registry.createField(collection.slug, { - slug: field.slug, - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - }); - result.fields.created++; - } + await upsertSeedField(db, collection.slug, field, existingField); + if (existingField) result.fields.updated++; + else result.fields.created++; } continue; } @@ -240,18 +219,7 @@ export async function applySeed( // Create fields for (const field of collection.fields) { - await registry.createField(collection.slug, { - slug: field.slug, - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - }); + await upsertSeedField(db, collection.slug, field, null); result.fields.created++; } } @@ -485,6 +453,13 @@ export async function applySeed( mediaContext, result, ); + // Reference fields are storage-less — route their resolved values to + // edges and keep them out of the column/revision data. + const { columnData, edges } = await splitReferenceFields( + db, + collectionSlug, + resolvedData, + ); // Update content + bylines + taxonomies atomically const status = entry.status || "published"; @@ -497,7 +472,7 @@ export async function applySeed( await trxContentRepo.update(collectionSlug, existing.id, { status, - data: resolvedData, + data: columnData, }); contentMutated = true; @@ -510,6 +485,7 @@ export async function applySeed( true, ); await applyContentTaxonomies(trx, collectionSlug, existing.id, entry, true); + await applyContentReferences(trx, collectionSlug, existing.id, edges); // Seed is declarative — when status is "published", promote to a live // revision so the admin UI shows "Unpublish" instead of "Save & Publish" @@ -522,7 +498,7 @@ export async function applySeed( const draft = await trxRevisionRepo.create({ collection: collectionSlug, entryId: existing.id, - data: resolvedData, + data: columnData, }); await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id); await trxContentRepo.publish(collectionSlug, existing.id); @@ -547,6 +523,13 @@ export async function applySeed( // Resolve $ref and $media in data const resolvedData = await resolveReferences(entry.data, seedIdMap, mediaContext, result); + // Reference fields are storage-less — route their resolved values to + // edges and keep them out of the column/revision data. + const { columnData, edges } = await splitReferenceFields( + db, + collectionSlug, + resolvedData, + ); // Resolve translationOf: map from seed-local ID to real EmDash ID let translationOf: string | undefined; @@ -574,7 +557,7 @@ export async function applySeed( type: collectionSlug, slug: entry.slug, status, - data: resolvedData, + data: columnData, locale: entryLocale, translationOf, publishedAt: status === "published" ? new Date().toISOString() : null, @@ -589,6 +572,7 @@ export async function applySeed( seedBylineIdMap, ); await applyContentTaxonomies(trx, collectionSlug, item.id, entry, false); + await applyContentReferences(trx, collectionSlug, item.id, edges); // Seed is declarative — when status is "published", promote to a live // revision so the admin UI shows "Unpublish" instead of "Save & Publish" @@ -981,6 +965,159 @@ async function applyContentBylines( * Apply taxonomy term assignments to a content entry. * In update mode, clears existing assignments before re-attaching. */ +/** + * Create or update a field from a seed. + * + * Reference fields are storage-less (migration 043): they persist no column, + * their edges live in `_emdash_content_references`, and each is backed by a + * relation definition. Seeds create fields through the registry (not the schema + * handler that owns the relation lifecycle), so this mirrors the handler — it + * creates the relation on first insert (field + relation in one transaction) + * and preserves the server-assigned `validation.relation`/`targetCollection` on + * re-apply, since a seed's field validation omits them and would otherwise + * orphan the relation. A reference field with no `targetCollection` cannot form + * a relation, so it is created as an inert storage-less field. + */ +async function upsertSeedField( + db: Kysely, + collectionSlug: string, + field: SeedField, + existing: Field | null, +): Promise { + if (existing) { + const validation = + field.type === "reference" && existing.validation?.relation + ? { + ...field.validation, + relation: existing.validation.relation, + targetCollection: existing.validation.targetCollection, + } + : field.validation; + const registry = new SchemaRegistry(db); + await registry.updateField(collectionSlug, field.slug, { + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + defaultValue: field.defaultValue, + validation, + widget: field.widget, + options: field.options, + }); + return; + } + + const input = { + slug: field.slug, + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + defaultValue: field.defaultValue, + validation: field.validation, + widget: field.widget, + options: field.options, + }; + + const targetCollection = + field.type === "reference" && typeof field.validation?.targetCollection === "string" + ? field.validation.targetCollection + : undefined; + + if (targetCollection) { + await withTransaction(db, async (trx) => { + const relation = await createFieldRelation( + trx, + collectionSlug, + field.slug, + field.label, + targetCollection, + ); + const registry = new SchemaRegistry(trx); + await registry.createField(collectionSlug, { + ...input, + validation: { ...field.validation, relation: relation.translationGroup }, + }); + }); + return; + } + + const registry = new SchemaRegistry(db); + await registry.createField(collectionSlug, input); +} + +/** + * Split resolved content `data` into the plain column data and the reference + * edge writes. Reference fields are storage-less, so a reference key left in + * `data` would hit the column writer (and `syncDataColumns` on publish) and + * throw "no such column". Their `$ref:`-resolved value — a child entry id or an + * array of them — is captured as an edge write instead, keyed by the field's + * relation group. A reference field with no relation drops its value (nothing + * can store it), matching the content handler's defensive strip. + */ +async function splitReferenceFields( + db: Kysely, + collectionSlug: string, + data: Record, +): Promise<{ + columnData: Record; + edges: Array<{ relationGroup: string; childIds: string[] }>; +}> { + const registry = new SchemaRegistry(db); + const collection = await registry.getCollectionWithFields(collectionSlug); + const referenceFields = new Map( + (collection?.fields ?? []).filter((f) => f.type === "reference").map((f) => [f.slug, f]), + ); + if (referenceFields.size === 0) return { columnData: data, edges: [] }; + + const columnData: Record = {}; + const edges: Array<{ relationGroup: string; childIds: string[] }> = []; + for (const [key, value] of Object.entries(data)) { + const field = referenceFields.get(key); + if (!field) { + columnData[key] = value; + continue; + } + const relationGroup = field.validation?.relation; + if (!relationGroup) continue; // inert reference field — nothing to store + const childIds = (Array.isArray(value) ? value : [value]).filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + edges.push({ relationGroup, childIds }); + } + return { columnData, edges }; +} + +/** + * Write reference edges for a content entry, replacing any existing set per + * relation (so re-applying a seed is idempotent). Throws to abort the enclosing + * transaction if a child entry cannot be resolved — a half-written entry is + * worse than a failed apply. + */ +async function applyContentReferences( + trx: Kysely, + collectionSlug: string, + contentId: string, + edges: Array<{ relationGroup: string; childIds: string[] }>, +): Promise { + for (const { relationGroup, childIds } of edges) { + const result = await setReferenceChildren( + trx, + collectionSlug, + contentId, + relationGroup, + childIds, + ); + if (!result.success) { + throw new Error( + `content.${collectionSlug}: failed to write references for "${contentId}": ${result.error.message}`, + ); + } + } +} + async function applyContentTaxonomies( db: Kysely, collectionSlug: string, diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 72c7d79599..db28216b63 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { BylineRepository } from "../../../src/database/repositories/byline.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RedirectRepository } from "../../../src/database/repositories/redirect.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; @@ -937,22 +938,28 @@ describe("applySeed", () => { expect(entry?.data.title).toBe("Existing"); }); - it("should resolve $ref: references between content", async () => { - const registry = new SchemaRegistry(db); - await registry.createCollection({ slug: "posts", label: "Posts" }); - await registry.createField("posts", { - slug: "title", - label: "Title", - type: "string", - }); - await registry.createField("posts", { - slug: "related_post", - label: "Related Post", - type: "reference", - }); - + it("should resolve $ref: references between content into reference edges", async () => { + // Reference fields are storage-less (migration 043): a seed defines the + // field (with its target collection), apply creates the backing relation, + // and a `$ref:` value in the field's data is written as a content-reference + // edge rather than a column value. const seed: SeedFile = { version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "related_post", + label: "Related Post", + type: "reference", + validation: { targetCollection: "posts" }, + }, + ], + }, + ], content: { posts: [ { id: "post-1", slug: "first", data: { title: "First" } }, @@ -973,8 +980,18 @@ describe("applySeed", () => { const first = await contentRepo.findBySlug("posts", "first"); const second = await contentRepo.findBySlug("posts", "second"); - // The reference should be resolved to the real ID - expect(second?.data.related_post).toBe(first?.id); + // Storage-less: the reference value is not persisted as a column. + expect(second?.data).not.toHaveProperty("related_post"); + + // It is stored as an edge, keyed at the translation group on both ends. + const relationRepo = new RelationRepository(db); + const relation = await relationRepo.findByName("posts_related_post"); + expect(relation).toBeTruthy(); + const edges = await relationRepo.getChildrenPage( + relation!.translationGroup, + second!.translationGroup!, + ); + expect(edges.items.map((e) => e.childGroup)).toEqual([first!.translationGroup]); }); it("should assign taxonomy terms to content", async () => { From 7c6d008732e80b20b8535a41084bcfdd93dd6b8b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:20:44 +0300 Subject: [PATCH 17/26] chore: changesets for reference field type Co-Authored-By: Claude Opus 4.8 --- .changeset/reference-field-admin.md | 5 +++++ .changeset/reference-field-core.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/reference-field-admin.md create mode 100644 .changeset/reference-field-core.md diff --git a/.changeset/reference-field-admin.md b/.changeset/reference-field-admin.md new file mode 100644 index 0000000000..0e83c2f274 --- /dev/null +++ b/.changeset/reference-field-admin.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Adds the reference field UI: configure a reference field in the schema editor (target collection and single/multiple), pick and reorder referenced entries in the entry editor — saved together with the entry in one request — and see read-only "Referenced by" backlinks on referenced entries. diff --git a/.changeset/reference-field-core.md b/.changeset/reference-field-core.md new file mode 100644 index 0000000000..e0813d5b11 --- /dev/null +++ b/.changeset/reference-field-core.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Reference fields are now storage-less: they no longer add a TEXT column to a collection's table, and their values are stored as content-reference edges instead. Selections ride in the content create/update body under a `references` key and are written atomically with the entry in a single transaction, and the content GET hydrates them alongside SEO and bylines. A reference field's config (relation, target collection, multiple) flows through the admin manifest, and its backing relation definition is created and removed together with the field. Seed files that use a reference field now apply its `$ref:` value as an edge (the seed shape is unchanged); the relation is created from the field's target collection. Existing reference columns keep their data but are no longer written. From 6861a4e7591afff6b5e20dcfa47de0b42830e082 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:32:17 +0300 Subject: [PATCH 18/26] feat(reference): display titles, route wiring, and config validation - 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 --- .changeset/reference-field-core.md | 2 +- .../admin/src/components/ContentEditor.tsx | 7 ++- .../src/components/ReferencesSidebar.tsx | 8 +-- packages/admin/src/lib/api/relations.ts | 2 + packages/core/src/api/handlers/relations.ts | 15 ++++++ packages/core/src/api/schemas/schema.ts | 5 ++ packages/core/src/astro/integration/routes.ts | 27 ++++++++++ .../core/src/database/repositories/types.ts | 1 + .../integration/api/references-edges.test.ts | 52 +++++++++++++++++++ .../unit/api/field-validation-schema.test.ts | 17 ++++++ packages/core/tests/unit/astro/routes.test.ts | 17 ++++++ 11 files changed, 147 insertions(+), 6 deletions(-) diff --git a/.changeset/reference-field-core.md b/.changeset/reference-field-core.md index e0813d5b11..225321275d 100644 --- a/.changeset/reference-field-core.md +++ b/.changeset/reference-field-core.md @@ -2,4 +2,4 @@ "emdash": minor --- -Reference fields are now storage-less: they no longer add a TEXT column to a collection's table, and their values are stored as content-reference edges instead. Selections ride in the content create/update body under a `references` key and are written atomically with the entry in a single transaction, and the content GET hydrates them alongside SEO and bylines. A reference field's config (relation, target collection, multiple) flows through the admin manifest, and its backing relation definition is created and removed together with the field. Seed files that use a reference field now apply its `$ref:` value as an edge (the seed shape is unchanged); the relation is created from the field's target collection. Existing reference columns keep their data but are no longer written. +Reference fields are now storage-less: they no longer add a TEXT column to a collection's table, and their values are stored as content-reference edges instead. Selections ride in the content create/update body under a `references` key and are written atomically with the entry in a single transaction, and the content GET hydrates them alongside SEO and bylines. Each resolved reference carries a display title sourced from the referenced entry's `title` (then `name`) field, 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, and its backing relation definition is created and removed together with the field. Seed files that use a reference field now apply its `$ref:` value as an edge (the seed shape is unchanged); the relation is created from the field's target collection. Existing reference columns keep their data but are no longer written. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index b9e52df142..d1dc6d701f 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -104,8 +104,9 @@ export interface FieldDescriptor { } /** - * A single staged reference row in the editor. `title` is populated by the - * picker for freshly added rows; hydrated rows fall back to slug/id for display. + * A single staged reference row in the editor. `title` comes from the picker + * for freshly added rows and from the server's resolved refs for hydrated rows; + * it falls back to slug/id for display when the entry has no title/name. */ export type ReferenceEntryRow = { id: string; @@ -133,6 +134,7 @@ function seedReferenceState(item?: ContentItem | null): Record ({ id: c.id, slug: c.slug, + title: c.title ?? undefined, locale: c.locale, })); out[group] = { baseline: rows, current: rows, nextCursor: page.nextCursor, loading: false }; @@ -530,6 +532,7 @@ export function ContentEditor({ const rows: ReferenceEntryRow[] = res.children.map((c) => ({ id: c.id, slug: c.slug, + title: c.title ?? undefined, locale: c.locale, })); setReferenceState((prev) => { diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx index 2fffed6504..0dd9951c38 100644 --- a/packages/admin/src/components/ReferencesSidebar.tsx +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -71,7 +71,9 @@ export function ReferencesSidebar({ collection, entryId, entryLocale }: Referenc void (async () => { for (const rel of applicableRelations) { try { - const res = await fetchReferenceParents(collection, entryId, rel.name, { + // The edge endpoints resolve a relation by id or translation_group, + // never by `name` — passing the group mirrors the children flow. + const res = await fetchReferenceParents(collection, entryId, rel.translationGroup, { limit: PAGE_SIZE, }); if (cancelled) return; @@ -109,7 +111,7 @@ export function ReferencesSidebar({ collection, entryId, entryLocale }: Referenc }); if (!cursor) return; try { - const res = await fetchReferenceParents(collection, entryId, rel.name, { + const res = await fetchReferenceParents(collection, entryId, rel.translationGroup, { cursor, limit: PAGE_SIZE, }); @@ -163,7 +165,7 @@ export function ReferencesSidebar({ collection, entryId, entryLocale }: Referenc search={{ locale: parent.locale ?? undefined }} className="font-medium hover:text-kumo-brand" > - {parent.slug ?? parent.id} + {parent.title || parent.slug || parent.id} ))} diff --git a/packages/admin/src/lib/api/relations.ts b/packages/admin/src/lib/api/relations.ts index 2aac9b5976..6df92397c0 100644 --- a/packages/admin/src/lib/api/relations.ts +++ b/packages/admin/src/lib/api/relations.ts @@ -19,6 +19,8 @@ export interface EntryRef { id: string; slug: string | null; collection: string; + /** Display label from the entry's title/name field; null when neither is set. */ + title: string | null; locale: string | null; sortOrder?: number; } diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index e88ff20fdd..cc1643db37 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -223,11 +223,25 @@ export type EntryRef = { id: string; slug: string | null; collection: string; + /** + * Display label sourced from the entry's `title`, then `name`, field — + * `null` when neither is set, leaving the client to fall back to slug/id. + * A stopgap until declarative display fields land; mirrors the admin's own + * title/name resolution. + */ + title: string | null; /** The actual locale of the resolved variant — see `pickVariant`. */ locale: string | null; sortOrder?: number; }; +/** Display title for a resolved entry: `title`, then `name`, else null. */ +function entryTitle(data: Record): string | null { + if (typeof data.title === "string" && data.title.length > 0) return data.title; + if (typeof data.name === "string" && data.name.length > 0) return data.name; + return null; +} + /** Resolve a relation from an id OR its translation_group. */ async function resolveRelation( repo: RelationRepository, @@ -296,6 +310,7 @@ export async function resolveEntries( id: entry.id, slug: entry.slug, collection, + title: entryTitle(entry.data), locale: entry.locale, sortOrder: edge.sortOrder, }); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f2922289e8..e5492327ee 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -70,6 +70,11 @@ const fieldValidation = z .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types") .max(64, "allowedMimeTypes may contain at most 64 entries") .optional(), + // Reference fields: the picker targets a collection and may allow more + // than one entry. Without these keys Zod strips them and the create + // handler rejects the field for a missing target collection. + targetCollection: z.string().min(1).optional(), + multiple: z.boolean().optional(), }) .optional(); diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 7b818cadba..1c7d5b1f21 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -181,6 +181,17 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id]/schedule.ts"), }); + // Reference field edge routes (children = parent side, parents = backlinks) + injectRoute({ + pattern: "/_emdash/api/content/[collection]/[id]/references/[relation]/children", + entrypoint: resolveRoute("api/content/[collection]/[id]/references/[relation]/children.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/content/[collection]/[id]/references/[relation]/parents", + entrypoint: resolveRoute("api/content/[collection]/[id]/references/[relation]/parents.ts"), + }); + // Revision management routes (for restore, etc.) injectRoute({ pattern: "/_emdash/api/revisions/[revisionId]", @@ -378,6 +389,22 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id]/terms/[taxonomy].ts"), }); + // Relation definition routes (reference field relations) + injectRoute({ + pattern: "/_emdash/api/relations", + entrypoint: resolveRoute("api/relations/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/relations/[id]", + entrypoint: resolveRoute("api/relations/[id]/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/relations/[id]/translations", + entrypoint: resolveRoute("api/relations/[id]/translations.ts"), + }); + // Plugin management routes (under /admin to avoid conflict with plugin API routes) injectRoute({ pattern: "/_emdash/api/admin/plugins", diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 89e214ce4e..c84ccadedf 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -276,6 +276,7 @@ export interface ContentItem { id: string; slug: string | null; collection: string; + title: string | null; locale: string | null; sortOrder?: number; }>; diff --git a/packages/core/tests/integration/api/references-edges.test.ts b/packages/core/tests/integration/api/references-edges.test.ts index 41b6bc0644..87b463c0a7 100644 --- a/packages/core/tests/integration/api/references-edges.test.ts +++ b/packages/core/tests/integration/api/references-edges.test.ts @@ -185,6 +185,34 @@ describeEachDialect("reference children handlers", (dialect) => { expect(result.error.code).toBe("NOT_FOUND"); }); + it("parents resolves by translation_group but not by relation name", async () => { + // The backlinks sidebar keys its fetch on the relation's translation_group + // (like the children flow), not its `name` — the resolver only accepts an + // id or a group, so a name-keyed read must 404. + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [child.id]); + + const byGroup = await handleReferenceParentsGet( + ctx.db, + "page", + child.id, + rel.translationGroup, + {}, + true, + ); + expect(byGroup.success).toBe(true); + if (!byGroup.success) return; + expect(byGroup.data.parents.map((p) => p.slug)).toEqual(["p"]); + + const byName = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.name, {}, true); + expect(byName.success).toBe(false); + if (byName.success) return; + expect(byName.error.code).toBe("NOT_FOUND"); + }); + it("entry on the wrong side (child collection) is VALIDATION_ERROR", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -224,6 +252,30 @@ describeEachDialect("reference children handlers", (dialect) => { expect(result.data.parents.every((p) => p.collection === "post")).toBe(true); }); + it("resolved refs carry a display title from the entry's title field", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "Parent Title" }, + }); + const titled = await content.create({ type: "page", slug: "t", data: { title: "Titled" } }); + // No title -> null, leaving the client to fall back to slug/id. + const untitled = await content.create({ type: "page", slug: "u", data: {} }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [titled.id, untitled.id]); + + const children = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + expect(children.success).toBe(true); + if (!children.success) return; + expect(children.data.children.map((c) => c.title)).toEqual(["Titled", null]); + + const parents = await handleReferenceParentsGet(ctx.db, "page", titled.id, rel.id, {}, true); + expect(parents.success).toBe(true); + if (!parents.success) return; + expect(parents.data.parents.map((p) => p.title)).toEqual(["Parent Title"]); + }); + it("parents rejects an entry on the parent side", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); diff --git a/packages/core/tests/unit/api/field-validation-schema.test.ts b/packages/core/tests/unit/api/field-validation-schema.test.ts index 8caf7c2884..29681d7563 100644 --- a/packages/core/tests/unit/api/field-validation-schema.test.ts +++ b/packages/core/tests/unit/api/field-validation-schema.test.ts @@ -36,3 +36,20 @@ describe("createFieldBody repeater sub-field types", () => { } }); }); + +describe("createFieldBody reference config", () => { + it("preserves targetCollection and multiple on the parsed validation", () => { + const result = createFieldBody.safeParse({ + slug: "author", + label: "Author", + type: "reference", + validation: { targetCollection: "authors", multiple: false }, + }); + + expect(result.success).toBe(true); + expect(result.data?.validation).toMatchObject({ + targetCollection: "authors", + multiple: false, + }); + }); +}); diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index dc632faa95..9aa8b96d80 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -73,6 +73,23 @@ describe("core media route injection", () => { ); }); + it("injects the relation and reference-edge API routes", () => { + // Regression: these route files existed but were never wired into + // injectCoreRoutes, so /_emdash/api/relations 404'd and the admin's + // "Referenced by" backlinks panel silently hid itself. + const routes = collectRoutePatterns(); + + expect(routes).toContain("/_emdash/api/relations"); + expect(routes).toContain("/_emdash/api/relations/[id]"); + expect(routes).toContain("/_emdash/api/relations/[id]/translations"); + expect(routes).toContain( + "/_emdash/api/content/[collection]/[id]/references/[relation]/children", + ); + expect(routes).toContain( + "/_emdash/api/content/[collection]/[id]/references/[relation]/parents", + ); + }); + it("injects default root SEO routes when the site does not define them", () => { const routes = collectRoutePatterns(); From 080a03963fe1dabada0ac05db7360bb40912fb39 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:21:12 +0300 Subject: [PATCH 19/26] Allow navigation to referenced items --- demos/simple/emdash-env.d.ts | 1 + .../admin/src/components/ContentEditor.tsx | 31 +- .../src/components/ContentPickerModal.tsx | 298 +++++++++++------ .../src/components/ContentSettingsPanel.tsx | 17 +- packages/admin/src/components/MenuEditor.tsx | 5 +- .../src/components/ReferencePickerModal.tsx | 315 ------------------ .../src/components/ReferencesSidebar.tsx | 90 ++++- 7 files changed, 306 insertions(+), 451 deletions(-) delete mode 100644 packages/admin/src/components/ReferencePickerModal.tsx diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 4c380e8e87..3fa12a1974 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -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 }; content?: PortableTextBlock[]; excerpt?: string; + relevant_posts?: string; createdAt: Date; updatedAt: Date; publishedAt: Date | null; diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 88c309d73a..d370520a37 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -25,6 +25,7 @@ import { Plus, Trash, } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -46,6 +47,7 @@ import { getLocaleDir } from "../locales/config.js"; import { useLocale } from "../locales/useLocale.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; +import { ContentPickerModal, type PickedContentEntry } from "./ContentPickerModal.js"; import { ContentSettingsPanel, DiscardDraftDialog, @@ -55,7 +57,6 @@ import { } from "./ContentSettingsPanel.js"; import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js"; import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js"; -import { ReferencePickerModal, type ReferencePickerRow } from "./ReferencePickerModal.js"; import { RepeaterField } from "./RepeaterField.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton.js"; @@ -1701,7 +1702,7 @@ function ReferenceFieldRenderer({ onChange(rows.filter((_, i) => i !== index)); }; - const handleConfirm = (picked: ReferencePickerRow[]) => { + const handleConfirm = (picked: PickedContentEntry[]) => { const additions: ReferenceEntryRow[] = picked.map((p) => ({ id: p.id, slug: p.slug, @@ -1733,15 +1734,33 @@ function ReferenceFieldRenderer({ key={row.id} className="flex items-center gap-2 rounded-md border bg-kumo-base px-3 py-2" > -
-
{referenceRowLabel(row)}
+ +
+ {referenceRowLabel(row)} +
{(row.slug || crossLocale) && (
{row.slug && {row.slug}} {crossLocale && {row.locale}}
)} -
+ + } + aria-label={t`Open ${referenceRowLabel(row)} in a new tab`} + /> {multiple && (
- void; - onSelect: (item: { collection: string; id: string; title: string }) => void; + /** + * Lock the picker to a single collection and hide the dropdown (reference + * fields). When omitted, a collection dropdown is shown (menus). + */ + collection?: string; + /** Allow staging several entries before confirming. Defaults to single-select. */ + multiple?: boolean; + /** Ids already linked in the target field — rendered checked and disabled. */ + selectedIds?: ReadonlySet; + /** Emit the chosen entries. Single-select emits a one-element array. */ + onConfirm: (rows: PickedContentEntry[]) => void; + /** Optional dialog title override. */ + title?: string; } function getItemTitle(item: { data: Record; slug: string | null; id: string }) { @@ -33,88 +65,104 @@ function getItemTitle(item: { data: Record; slug: string | null ); } -export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) { +const EMPTY_SELECTED: ReadonlySet = new Set(); + +export function ContentPickerModal({ + open, + onOpenChange, + collection, + multiple = false, + selectedIds = EMPTY_SELECTED, + onConfirm, + title, +}: ContentPickerModalProps) { const { t } = useLingui(); + const locked = !!collection; const [searchQuery, setSearchQuery] = React.useState(""); const debouncedSearch = useDebouncedValue(searchQuery, 300); - const [selectedCollection, setSelectedCollection] = React.useState(""); - const [allItems, setAllItems] = React.useState([]); - const [nextCursor, setNextCursor] = React.useState(); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [dropdownCollection, setDropdownCollection] = React.useState(""); + // Staged picks (multiple mode) — keyed by id so we retain title/slug. + const [picked, setPicked] = React.useState>({}); const { data: collections = [] } = useQuery({ queryKey: ["collections"], queryFn: fetchCollections, - enabled: open, + enabled: open && !locked, }); - // Default to first collection when collections load + // Default the dropdown to the first collection once collections load. React.useEffect(() => { - if (collections.length > 0 && !selectedCollection) { - setSelectedCollection(collections[0]!.slug); + if (!locked && collections.length > 0 && !dropdownCollection) { + setDropdownCollection(collections[0]!.slug); } - }, [collections, selectedCollection]); + }, [locked, collections, dropdownCollection]); - const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["content-picker", selectedCollection, { limit: 50 }], - queryFn: () => fetchContentList(selectedCollection, { limit: 50 }), - enabled: open && !!selectedCollection, - }); + const activeCollection = collection ?? dropdownCollection; - // Sync initial page into accumulated items + // Reset transient UI state when the modal opens. Result pages come from the + // query cache (below), so there is nothing to re-fetch or re-sync here. React.useEffect(() => { - if (contentResult) { - setAllItems(contentResult.items); - setNextCursor(contentResult.nextCursor); + if (open) { + setSearchQuery(""); + setPicked({}); + if (!locked) setDropdownCollection(""); } - }, [contentResult]); + }, [open, locked]); - const handleLoadMore = async () => { - if (!nextCursor || isLoadingMore) return; - setIsLoadingMore(true); - try { - const result = await fetchContentList(selectedCollection, { + const trimmedSearch = debouncedSearch.trim(); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["content-picker", activeCollection, trimmedSearch], + queryFn: ({ pageParam }) => + fetchContentList(activeCollection, { limit: 50, - cursor: nextCursor, - }); - setAllItems((prev) => [...prev, ...result.items]); - setNextCursor(result.nextCursor); - } finally { - setIsLoadingMore(false); - } - }; - - const filteredItems = React.useMemo(() => { - if (!debouncedSearch) return allItems; - const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [allItems, debouncedSearch]); + cursor: pageParam, + search: trimmedSearch || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open && !!activeCollection, + }); - // Reset state when modal opens or collection changes - React.useEffect(() => { - if (open) { - setSearchQuery(""); - setSelectedCollection(""); - setAllItems([]); - setNextCursor(undefined); - } - }, [open]); + const items = React.useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data]); - const handleSelect = (item: ContentItem) => { - onSelect({ - collection: selectedCollection, - id: item.id, - title: getItemTitle(item), + const togglePicked = (item: ContentItem) => { + setPicked((prev) => { + const next = { ...prev }; + if (next[item.id]) { + delete next[item.id]; + } else { + next[item.id] = { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getItemTitle(item), + }; + } + return next; }); + }; + + const handleSingleChoose = (item: ContentItem) => { + onConfirm([ + { collection: activeCollection, id: item.id, slug: item.slug, title: getItemTitle(item) }, + ]); + onOpenChange(false); + }; + + const handleConfirmMultiple = () => { + onConfirm(Object.values(picked)); onOpenChange(false); }; + const pickedCount = Object.keys(picked).length; + const dialogTitle = title ?? (multiple ? t`Add references` : t`Select content`); + return (
- {t`Select Content`} + {dialogTitle}
- {/* Search and collection filter */} + {/* Search and (unlocked) collection filter */}
@@ -145,27 +193,28 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick autoFocus />
- { + setDropdownCollection(v ?? ""); + setPicked({}); + }} + items={Object.fromEntries(collections.map((col) => [col.slug, col.label]))} + aria-label={t`Collection`} + /> + )}
{/* Content list */}
- {contentLoading ? ( + {isLoading ? (
{t`Loading content...`}
- ) : filteredItems.length === 0 ? ( + ) : items.length === 0 ? (
- {searchQuery ? ( + {trimmedSearch ? ( <>

{t`No content found`}

@@ -180,55 +229,91 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
) : (
- {filteredItems.map((item) => { + {items.map((item) => { const status = getDraftStatus(item); + const alreadyLinked = selectedIds.has(item.id); + const isPicked = alreadyLinked || !!picked[item.id]; + const statusDot = ( + + ); + const statusLabel = + status === "published" + ? t`Published` + : status === "published_with_changes" + ? t`Modified` + : t`Draft`; + const meta = ( +
+ {statusDot} + {statusLabel} + {item.slug && ( + <> + / + {item.slug} + + )} +
+ ); + + if (multiple) { + return ( + + ); + } + return ( ); })} - {nextCursor && !searchQuery && ( + {hasNextPage && (
+ {multiple && ( + + )}
diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 4b26f87734..f1ec49671c 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -559,15 +559,16 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ /> )} - {/* Referenced by — read-only backlinks for existing entries */} + {/* Referenced by — read-only backlinks for existing entries. Renders + nothing (no chrome) when the entry has no backlinks, so it owns its + own section padding/border rather than being wrapped. */} {item && !isNew && ( -
- -
+ )} {hasSeo && !isNew && onSeoChange && ( diff --git a/packages/admin/src/components/MenuEditor.tsx b/packages/admin/src/components/MenuEditor.tsx index 4e4154e2b6..7233276afc 100644 --- a/packages/admin/src/components/MenuEditor.tsx +++ b/packages/admin/src/components/MenuEditor.tsx @@ -450,7 +450,10 @@ export function MenuEditor() { { + const item = rows[0]; + if (item) handleAddContent(item); + }} /> {i18n && i18n.locales.length > 1 && menu ? ( diff --git a/packages/admin/src/components/ReferencePickerModal.tsx b/packages/admin/src/components/ReferencePickerModal.tsx deleted file mode 100644 index 320919a88d..0000000000 --- a/packages/admin/src/components/ReferencePickerModal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Reference Picker Modal - * - * A modal for choosing entries to link into a reference field. Unlike the - * shared ContentPickerModal it is locked to a single target collection (no - * collection dropdown) and supports multi-select + confirm when the field is - * `multiple`, or single click-to-choose when it isn't. Chosen entries are - * emitted with their resolved titles so the field can render labels for newly - * added rows without a refetch. - */ - -import { Button, Checkbox, Dialog, Input, Loader } from "@cloudflare/kumo"; -import { useLingui } from "@lingui/react/macro"; -import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; -import * as React from "react"; - -import { fetchContentList, getDraftStatus } from "../lib/api"; -import type { ContentItem } from "../lib/api"; -import { useDebouncedValue } from "../lib/hooks"; -import { cn } from "../lib/utils"; - -/** A chosen reference entry, carrying a display title for the field renderer. */ -export interface ReferencePickerRow { - id: string; - slug: string | null; - title: string; -} - -interface ReferencePickerModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** The reference field's target collection. */ - collection: string; - /** Whether the field accepts multiple entries. */ - multiple: boolean; - /** Ids already selected in the field — rendered checked and disabled. */ - selectedIds: Set; - /** Emit the chosen entries. For single-select this is a one-element array. */ - onConfirm: (rows: ReferencePickerRow[]) => void; -} - -function getItemTitle(item: { data: Record; slug: string | null; id: string }) { - const rawTitle = item.data.title; - const rawName = item.data.name; - return ( - (typeof rawTitle === "string" ? rawTitle : "") || - (typeof rawName === "string" ? rawName : "") || - item.slug || - item.id - ); -} - -export function ReferencePickerModal({ - open, - onOpenChange, - collection, - multiple, - selectedIds, - onConfirm, -}: ReferencePickerModalProps) { - const { t } = useLingui(); - const [searchQuery, setSearchQuery] = React.useState(""); - const debouncedSearch = useDebouncedValue(searchQuery, 300); - const [allItems, setAllItems] = React.useState([]); - const [nextCursor, setNextCursor] = React.useState(); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); - // Staged picks (multi-select mode) — keyed by id so we retain title/slug. - const [picked, setPicked] = React.useState>({}); - - // Server-side search: the query key includes the debounced term so a new - // slice is fetched rather than filtered client-side (mirrors the list view). - const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["reference-picker", collection, { search: debouncedSearch, limit: 50 }], - queryFn: () => - fetchContentList(collection, { - limit: 50, - search: debouncedSearch || undefined, - }), - enabled: open && !!collection, - }); - - // Sync the first page into the accumulated list whenever the query result - // changes (new search term or reopen). - React.useEffect(() => { - if (contentResult) { - setAllItems(contentResult.items); - setNextCursor(contentResult.nextCursor); - } - }, [contentResult]); - - // Reset transient state when the modal opens. - React.useEffect(() => { - if (open) { - setSearchQuery(""); - setAllItems([]); - setNextCursor(undefined); - setPicked({}); - } - }, [open]); - - const handleLoadMore = async () => { - if (!nextCursor || isLoadingMore) return; - setIsLoadingMore(true); - try { - const result = await fetchContentList(collection, { - limit: 50, - cursor: nextCursor, - search: debouncedSearch || undefined, - }); - setAllItems((prev) => [...prev, ...result.items]); - setNextCursor(result.nextCursor); - } finally { - setIsLoadingMore(false); - } - }; - - const togglePicked = (item: ContentItem) => { - setPicked((prev) => { - const next = { ...prev }; - if (next[item.id]) { - delete next[item.id]; - } else { - next[item.id] = { id: item.id, slug: item.slug, title: getItemTitle(item) }; - } - return next; - }); - }; - - const handleSingleChoose = (item: ContentItem) => { - onConfirm([{ id: item.id, slug: item.slug, title: getItemTitle(item) }]); - onOpenChange(false); - }; - - const handleConfirmMultiple = () => { - onConfirm(Object.values(picked)); - onOpenChange(false); - }; - - const pickedCount = Object.keys(picked).length; - - return ( - - -
- - {multiple ? t`Add references` : t`Choose a reference`} - - ( - - )} - /> -
- - {/* Search */} -
-
- - setSearchQuery(e.target.value)} - className="ps-10" - autoFocus - /> -
-
- - {/* Content list */} -
- {contentLoading ? ( -
-
{t`Loading content...`}
-
- ) : allItems.length === 0 ? ( -
- {debouncedSearch ? ( - <> - -

{t`No content found`}

-

{t`Try adjusting your search`}

- - ) : ( - <> - -

{t`No content in this collection`}

- - )} -
- ) : ( -
- {allItems.map((item) => { - const status = getDraftStatus(item); - const alreadyLinked = selectedIds.has(item.id); - const isPicked = alreadyLinked || !!picked[item.id]; - const statusDot = ( - - ); - const statusLabel = - status === "published" - ? t`Published` - : status === "published_with_changes" - ? t`Modified` - : t`Draft`; - const meta = ( -
- {statusDot} - {statusLabel} - {item.slug && ( - <> - / - {item.slug} - - )} -
- ); - - if (multiple) { - return ( - - ); - } - - return ( - - ); - })} - {nextCursor && ( -
- -
- )} -
- )} -
- - {/* Footer */} -
- - {multiple && ( - - )} -
-
-
- ); -} diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx index 0dd9951c38..66067dddce 100644 --- a/packages/admin/src/components/ReferencesSidebar.tsx +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -12,18 +12,22 @@ * nobody references stay out of the way rather than showing an empty state. */ -import { Button, Loader } from "@cloudflare/kumo"; +import { Badge, Button, Loader, Text } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; +import { ArrowSquareOut } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; +import { fetchCollections } from "../lib/api"; import { type EntryRef, type RelationDef, fetchReferenceParents, fetchRelations, } from "../lib/api/relations.js"; +import { cn } from "../lib/utils.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; interface ReferencesSidebarProps { collection: string; @@ -31,6 +35,10 @@ interface ReferencesSidebarProps { /** Locale of the entry being edited. Scopes the relation definitions read so * labels localize to the entry's translation. */ entryLocale?: string; + /** Applied to the root element. The panel renders nothing (no chrome) when + * there are no backlinks, so the caller passes section padding/border here + * rather than wrapping — an empty wrapper would leave a stray gap. */ + className?: string; } interface ParentsState { @@ -41,7 +49,12 @@ interface ParentsState { const PAGE_SIZE = 50; -export function ReferencesSidebar({ collection, entryId, entryLocale }: ReferencesSidebarProps) { +export function ReferencesSidebar({ + collection, + entryId, + entryLocale, + className, +}: ReferencesSidebarProps) { const { t } = useLingui(); const relationsQuery = useQuery({ queryKey: ["relations", entryLocale ?? null], @@ -51,6 +64,19 @@ export function ReferencesSidebar({ collection, entryId, entryLocale }: Referenc retry: false, }); + // Group headings use the parent collection's plural label. The relation only + // carries `parentLabel` (the singular field label), so map slugs to their + // plural display name; the query is shared with the rest of the admin. + const collectionsQuery = useQuery({ + queryKey: ["collections"], + queryFn: fetchCollections, + }); + const pluralLabelBySlug = React.useMemo(() => { + const map = new Map(); + for (const c of collectionsQuery.data ?? []) map.set(c.slug, c.label); + return map; + }, [collectionsQuery.data]); + const applicableRelations = React.useMemo( () => (relationsQuery.data ?? []).filter((r) => r.childCollection === collection), [relationsQuery.data, collection], @@ -145,30 +171,60 @@ export function ReferencesSidebar({ collection, entryId, entryLocale }: Referenc const populatedRels = applicableRelations.filter(isPopulated); return ( -
-

{t`Referenced by`}

+
+ + {t`Referenced by`} +
{populatedRels.map((rel) => { const state = parentsByRel[rel.id]; // `state` is guarded by `isPopulated` above, but the TS narrowing // across the .filter callback doesn't carry through. if (!state) return null; + const heading = pluralLabelBySlug.get(rel.parentCollection) ?? rel.parentLabel; return (
-

{rel.parentLabel}

-
    - {state.items.map((parent) => ( -
  • - {heading} +
      + {state.items.map((parent) => { + const crossLocale = + !!parent.locale && !!entryLocale && parent.locale !== entryLocale; + const label = parent.title || parent.slug || parent.id; + return ( +
    • - {parent.title || parent.slug || parent.id} - -
    • - ))} + +
      + {label} +
      + {(parent.slug || crossLocale) && ( +
      + {parent.slug && {parent.slug}} + {crossLocale && {parent.locale}} +
      + )} + + } + aria-label={t`Open ${label} in a new tab`} + /> + + ); + })}
    {state.nextCursor && (
    From 25664b6227476936d18fad6eb90166eb62281df5 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:27:08 +0300 Subject: [PATCH 20/26] Update changesets --- .changeset/reference-field-admin.md | 2 +- .changeset/reference-field-core.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/reference-field-admin.md b/.changeset/reference-field-admin.md index 0e83c2f274..3bd69a246d 100644 --- a/.changeset/reference-field-admin.md +++ b/.changeset/reference-field-admin.md @@ -2,4 +2,4 @@ "@emdash-cms/admin": minor --- -Adds the reference field UI: configure a reference field in the schema editor (target collection and single/multiple), pick and reorder referenced entries in the entry editor — saved together with the entry in one request — and see read-only "Referenced by" backlinks on referenced entries. +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. diff --git a/.changeset/reference-field-core.md b/.changeset/reference-field-core.md index 225321275d..30cf6af8f3 100644 --- a/.changeset/reference-field-core.md +++ b/.changeset/reference-field-core.md @@ -2,4 +2,4 @@ "emdash": minor --- -Reference fields are now storage-less: they no longer add a TEXT column to a collection's table, and their values are stored as content-reference edges instead. Selections ride in the content create/update body under a `references` key and are written atomically with the entry in a single transaction, and the content GET hydrates them alongside SEO and bylines. Each resolved reference carries a display title sourced from the referenced entry's `title` (then `name`) field, 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, and its backing relation definition is created and removed together with the field. Seed files that use a reference field now apply its `$ref:` value as an edge (the seed shape is unchanged); the relation is created from the field's target collection. Existing reference columns keep their data but are no longer written. +Reference fields now store real relationships between entries instead of an inert string. Selections are saved as first-class content-reference edges, written atomically with the entry in a single transaction, and hydrated on read alongside SEO and bylines — each resolved reference carries a readable display title (from the referenced entry's `title`, then `name`) so pickers and backlinks show a label instead of a slug. Reference fields are storage-less: they no longer add a column to a collection's table, and "Referenced by" backlinks come for free from the edge data. Seed files using a reference field apply its `$ref:` value as an edge (seed shape unchanged). Upgrade note: existing reference columns keep their data but are no longer written to. From 45e018f084d902b896b4bfab7519436fea891f62 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:09:20 +0300 Subject: [PATCH 21/26] Address review --- .../admin/src/components/ContentEditor.tsx | 16 +++-- .../src/components/ReferencesSidebar.tsx | 27 ++++++-- packages/core/src/api/handlers/content.ts | 11 ++++ packages/core/src/api/handlers/schema.ts | 9 ++- .../src/database/repositories/relation.ts | 33 ++++++++++ .../content/content-references-write.test.ts | 66 ++++++++++++++++++- .../schema/reference-field-lifecycle.test.ts | 47 +++++++++++++ 7 files changed, 195 insertions(+), 14 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index d370520a37..1171065c31 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -120,6 +120,9 @@ type ReferenceGroupState = { /** Set while more pages of the hydrated set remain to be loaded. */ nextCursor?: string; loading: boolean; + /** Set when a page load failed. Stops auto-paging so a failing request never + * retries in a tight loop; cleared when the state is reseeded for a new entry. */ + error?: boolean; }; /** Seed reference state from a hydrated item (first page per relation group). */ @@ -574,7 +577,9 @@ export function ContentEditor({ } catch { setReferenceState((prev) => { const cur = prev[group]; - return cur ? { ...prev, [group]: { ...cur, loading: false } } : prev; + // Flag the failure so the auto-page effect stops retrying — clearing + // only `loading` would leave `nextCursor` set and spin the request. + return cur ? { ...prev, [group]: { ...cur, loading: false, error: true } } : prev; }); } }, @@ -1677,15 +1682,18 @@ function ReferenceFieldRenderer({ const rows = state?.current ?? []; const nextCursor = state?.nextCursor; const loading = state?.loading ?? false; + const loadError = state?.error ?? false; // Reorder/remove are gated until the full hydrated set is loaded, so a save // can never emit a truncated list that would delete the unloaded tail. const fullyLoaded = !nextCursor && !loading; // Auto-page the remaining hydrated set so the field is edit-ready. Chains: - // each load advances `nextCursor`, re-firing until the set is exhausted. + // each load advances `nextCursor`, re-firing until the set is exhausted. A + // failed page sets `error`, which halts the chain so a throwing request never + // retries in a tight loop; reseeding for a new entry clears it. React.useEffect(() => { - if (nextCursor && !loading) onLoadMore(); - }, [nextCursor, loading, onLoadMore]); + if (nextCursor && !loading && !loadError) onLoadMore(); + }, [nextCursor, loading, loadError, onLoadMore]); const selectedIds = React.useMemo(() => new Set(rows.map((r) => r.id)), [rows]); diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx index 66067dddce..792caae632 100644 --- a/packages/admin/src/components/ReferencesSidebar.tsx +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -56,9 +56,14 @@ export function ReferencesSidebar({ className, }: ReferencesSidebarProps) { const { t } = useLingui(); + // Fetch every relation definition (no locale scope). The reference-field + // lifecycle creates a relation row only in the default locale, so scoping the + // read to a non-default `entryLocale` would return no defs and wrongly hide the + // panel; instead we dedupe by translation group below, preferring the + // entry-locale translation when one exists. const relationsQuery = useQuery({ - queryKey: ["relations", entryLocale ?? null], - queryFn: () => fetchRelations(entryLocale), + queryKey: ["relations"], + queryFn: () => fetchRelations(), // A 403 means the viewer genuinely lacks `schema:read`; retrying just adds // latency before we hide the panel, which is the desired outcome. retry: false, @@ -77,10 +82,20 @@ export function ReferencesSidebar({ return map; }, [collectionsQuery.data]); - const applicableRelations = React.useMemo( - () => (relationsQuery.data ?? []).filter((r) => r.childCollection === collection), - [relationsQuery.data, collection], - ); + // One relation per translation group whose child side is this collection. + // Prefer the entry-locale translation for localized labels; fall back to any + // sibling (currently the default-locale row) so backlinks still surface. + const applicableRelations = React.useMemo(() => { + const byGroup = new Map(); + for (const r of relationsQuery.data ?? []) { + if (r.childCollection !== collection) continue; + const existing = byGroup.get(r.translationGroup); + if (!existing || (entryLocale && r.locale === entryLocale)) { + byGroup.set(r.translationGroup, r); + } + } + return [...byGroup.values()]; + }, [relationsQuery.data, collection, entryLocale]); // Per-relation parents pagination, keyed by relation id. First pages are // loaded on demand (effect below) once the relations list resolves; manual diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 3b2346460c..c36e8f562a 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -1241,8 +1241,19 @@ export async function handleContentDuplicate( const repo = new ContentRepository(trx); const bylineRepo = new BylineRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; + const original = await repo.findById(collection, resolvedId); const dup = await repo.duplicate(collection, resolvedId, authorId); + // Reference edges are storage-less (keyed by translation_group, not in + // `data`), so they don't ride along in the row copy — carry the original's + // outgoing references onto the duplicate explicitly. + if (original?.translationGroup && dup.translationGroup) { + await new RelationRepository(trx).copyParentEdges( + original.translationGroup, + dup.translationGroup, + ); + } + const existingBylines = await bylineRepo.getContentBylines(collection, resolvedId); if (existingBylines.length > 0) { await bylineRepo.setContentBylines( diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 52a27b7d2d..700dd6f020 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -29,7 +29,7 @@ const RELATION_NAME_MAX_ATTEMPTS = 5; * fingerprint used in the relations API handler. */ function isUniqueViolation(error: unknown): boolean { const message = error instanceof Error ? error.message.toLowerCase() : ""; - return message.includes("unique") || message.includes("duplicate"); + return message.includes("unique constraint failed") || message.includes("duplicate key"); } /** @@ -496,9 +496,12 @@ export async function handleSchemaFieldUpdate( if (input.label !== undefined && input.label !== existing.label) { const relations = new RelationRepository(trx); + // Update every translation in the group so the relation's localized + // labels stay in sync, not just the first sibling. const siblings = await relations.findTranslations(relationGroup); - const target = siblings[0]; - if (target) await relations.update(target.id, { childLabel: input.label }); + for (const sibling of siblings) { + await relations.update(sibling.id, { childLabel: input.label }); + } } return updated; diff --git a/packages/core/src/database/repositories/relation.ts b/packages/core/src/database/repositories/relation.ts index fbb71d1bb8..f5f9628e1f 100644 --- a/packages/core/src/database/repositories/relation.ts +++ b/packages/core/src/database/repositories/relation.ts @@ -462,6 +462,39 @@ export class RelationRepository { .execute(); } + /** + * Copy every outgoing edge of `fromParentGroup` onto `toParentGroup`, + * preserving relation, child, and sort order. Used when duplicating a content + * entry so the copy carries the same reference selections (edges are + * storage-less, keyed by translation_group, so they don't ride along in the + * row's `data`). Only the parent side is copied — backlinks pointing at the + * original are intentionally left alone. Idempotent per edge via onConflict. + */ + async copyParentEdges(fromParentGroup: string, toParentGroup: string): Promise { + const rows = await this.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("parent_group", "=", fromParentGroup) + .execute(); + if (rows.length === 0) return; + + const now = new Date().toISOString(); + await this.db + .insertInto("_emdash_content_references") + .values( + rows.map((row) => ({ + id: ulid(), + relation_group: row.relation_group, + parent_group: toParentGroup, + child_group: row.child_group, + sort_order: row.sort_order, + created_at: now, + })), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + /** * Backlink traversal, paginated: one page of the parents that reference a * child for a relation, ordered by `id`. Unlike a parent's children, a diff --git a/packages/core/tests/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts index 7f9a26c116..901e8cbfa5 100644 --- a/packages/core/tests/integration/content/content-references-write.test.ts +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -1,6 +1,10 @@ import { expect, it } from "vitest"; -import { handleContentCreate, handleContentGet } from "../../../src/api/handlers/content.js"; +import { + handleContentCreate, + handleContentDuplicate, + handleContentGet, +} from "../../../src/api/handlers/content.js"; import { setReferenceChildren } from "../../../src/api/handlers/relations.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RelationRepository } from "../../../src/database/repositories/relation.js"; @@ -331,3 +335,63 @@ describeEachDialect("handleContentGet reference hydration (opt-in)", (dialect) = } }); }); + +describeEachDialect("handleContentDuplicate copies reference edges", (dialect) => { + let ctx: DialectTestContext; + + it("carries the original's outgoing references onto the duplicate", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(ctx.db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const childA = await handleContentCreate(ctx.db, "posts", { data: { title: "Child A" } }); + const childB = await handleContentCreate(ctx.db, "posts", { data: { title: "Child B" } }); + expect(parent.success && childA.success && childB.success).toBe(true); + if (!parent.success || !childA.success || !childB.success) return; + + const set = await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.translationGroup, + [childA.data.item.id, childB.data.item.id], + ); + expect(set.success).toBe(true); + + const dup = await handleContentDuplicate(ctx.db, "posts", parent.data.item.id); + expect(dup.success).toBe(true); + if (!dup.success) return; + + // The duplicate is a distinct entry (new translation_group) but must carry + // the same outgoing reference edges, in order. + const content = new ContentRepository(ctx.db); + const dupItem = await content.findById("posts", dup.data.item.id); + expect(dupItem?.translationGroup).toBeTruthy(); + expect(dupItem?.translationGroup).not.toBe(parent.data.item.id); + if (!dupItem?.translationGroup) return; + + const page = await relationRepo.getChildrenPage( + relation.translationGroup, + dupItem.translationGroup, + ); + expect(page.items.map((i) => i.childGroup)).toEqual([ + childA.data.item.id, + childB.data.item.id, + ]); + } finally { + await teardownForDialect(ctx); + } + }); +}); diff --git a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts index b977861d83..c3100a9fcc 100644 --- a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -145,6 +145,53 @@ describeEachDialect("reference field lifecycle", (dialect) => { } }); + it("updates childLabel on every translation in the group, not just the first", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const created = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + expect(created.success).toBe(true); + if (!created.success) return; + const relationGroup = created.data.item.validation?.relation; + expect(relationGroup).toBeTruthy(); + if (!relationGroup) return; + + // Add a second-locale translation of the relation def so the group has + // more than one sibling to keep in sync. + const relRepo = new RelationRepository(ctx.db); + const base = (await relRepo.findTranslations(relationGroup))[0]; + expect(base).toBeTruthy(); + if (!base) return; + await relRepo.create({ + name: base.name, + translationOf: base.id, + locale: "fr", + parentLabel: base.parentLabel, + childLabel: base.childLabel, + }); + + const updated = await handleSchemaFieldUpdate(ctx.db, "posts", "related", { + label: "Related posts", + }); + expect(updated.success).toBe(true); + + const siblings = await relRepo.findTranslations(relationGroup); + expect(siblings.length).toBe(2); + for (const sibling of siblings) { + expect(sibling.childLabel).toBe("Related posts"); + } + } finally { + await teardownForDialect(ctx); + } + }); + it("preserves relation and targetCollection when validation is explicitly null", async () => { ctx = await setupForDialect(dialect); try { From 850285f3074bfd1f8c4d38c8e8249a231585e7d2 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:41:09 +0300 Subject: [PATCH 22/26] Address re-review: schema title, target validation, locale badges - Add `title` to entryRefSchema so the response contract matches the runtime EntryRef (no more OpenAPI/generated-type drift). - Validate the target collection in createFieldRelation, rejecting a reference field whose target does not exist (rolls back in-transaction, leaving no orphan field or relation row). Adds a lifecycle test. - Remove the misleading cross-locale badge from the reference list and backlinks sidebar: edges are keyed by translation_group, so labelling a resolved variant's locale implies a per-locale binding that doesn't exist. Titles already resolve at the current entry's locale via pickVariant. Drops the now-dead entryLocale plumbing. Co-Authored-By: Claude Opus 4.8 --- .../admin/src/components/ContentEditor.tsx | 13 ++-------- .../src/components/ReferencesSidebar.tsx | 9 +++---- packages/core/src/api/handlers/schema.ts | 7 ++++++ packages/core/src/api/schemas/relations.ts | 3 +++ .../schema/reference-field-lifecycle.test.ts | 24 +++++++++++++++++++ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 1171065c31..acaf890b46 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1006,7 +1006,6 @@ export function ContentEditor({ referenceState={referenceState} onReferenceChange={handleReferenceCurrentChange} onLoadMoreReferences={handleLoadMoreReferences} - entryLocale={item?.locale ?? entryLocale ?? null} /> ); return fieldEl; @@ -1268,8 +1267,6 @@ interface FieldRendererProps { onReferenceChange?: (group: string, rows: ReferenceEntryRow[]) => void; /** Page the rest of a relation's hydrated set. */ onLoadMoreReferences?: (group: string) => void; - /** The entry's locale, for the reference row cross-locale hint. */ - entryLocale?: string | null; } /** @@ -1289,7 +1286,6 @@ function FieldRenderer({ referenceState, onReferenceChange, onLoadMoreReferences, - entryLocale, }: FieldRendererProps) { const { t } = useLingui(); const pluginAdmins = usePluginAdmins(); @@ -1597,7 +1593,6 @@ function FieldRenderer({ targetCollection={targetCollection} multiple={multiple} state={referenceState?.[relationGroup]} - entryLocale={entryLocale ?? null} onChange={(rows) => onReferenceChange?.(relationGroup, rows)} onLoadMore={() => onLoadMoreReferences?.(relationGroup)} /> @@ -1663,7 +1658,6 @@ function ReferenceFieldRenderer({ targetCollection, multiple, state, - entryLocale, onChange, onLoadMore, }: { @@ -1672,7 +1666,6 @@ function ReferenceFieldRenderer({ targetCollection: string; multiple: boolean; state?: ReferenceGroupState; - entryLocale: string | null; onChange: (rows: ReferenceEntryRow[]) => void; onLoadMore: () => void; }) { @@ -1736,7 +1729,6 @@ function ReferenceFieldRenderer({ ) : (
      {rows.map((row, index) => { - const crossLocale = !!row.locale && !!entryLocale && row.locale !== entryLocale; return (
    • {referenceRowLabel(row)}
    - {(row.slug || crossLocale) && ( + {row.slug && (
    - {row.slug && {row.slug}} - {crossLocale && {row.locale}} + {row.slug}
    )} diff --git a/packages/admin/src/components/ReferencesSidebar.tsx b/packages/admin/src/components/ReferencesSidebar.tsx index 792caae632..aa5e3810fb 100644 --- a/packages/admin/src/components/ReferencesSidebar.tsx +++ b/packages/admin/src/components/ReferencesSidebar.tsx @@ -12,7 +12,7 @@ * nobody references stay out of the way rather than showing an empty state. */ -import { Badge, Button, Loader, Text } from "@cloudflare/kumo"; +import { Button, Loader, Text } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { ArrowSquareOut } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; @@ -202,8 +202,6 @@ export function ReferencesSidebar({

    {heading}

      {state.items.map((parent) => { - const crossLocale = - !!parent.locale && !!entryLocale && parent.locale !== entryLocale; const label = parent.title || parent.slug || parent.id; return (
    • {label}
- {(parent.slug || crossLocale) && ( + {parent.slug && (
- {parent.slug && {parent.slug}} - {crossLocale && {parent.locale}} + {parent.slug}
)} diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 700dd6f020..a9a218fbfd 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -53,6 +53,13 @@ export async function createFieldRelation( throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); } + if (!(await registry.getCollection(targetCollection))) { + throw new SchemaError( + `Target collection "${targetCollection}" not found`, + "COLLECTION_NOT_FOUND", + ); + } + const baseName = `${collectionSlug}_${fieldSlug}`.slice(0, 63); for (let attempt = 0; attempt < RELATION_NAME_MAX_ATTEMPTS; attempt++) { const suffix = attempt === 0 ? "" : `_${attempt + 1}`; diff --git a/packages/core/src/api/schemas/relations.ts b/packages/core/src/api/schemas/relations.ts index 12bdc9e2d9..3ba0fa9342 100644 --- a/packages/core/src/api/schemas/relations.ts +++ b/packages/core/src/api/schemas/relations.ts @@ -91,6 +91,9 @@ export const entryRefSchema = z id: z.string(), slug: z.string().nullable(), collection: z.string(), + // Display label sourced from the entry's `title`, then `name`, field — + // `null` when neither is set. Mirrors the runtime `EntryRef`. + title: z.string().nullable(), // The actual locale of the resolved variant. When no variant matches the // requesting entry's locale, the ref falls back to another locale's row; // this field makes that substitution explicit instead of silently diff --git a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts index c3100a9fcc..69ef8a65a9 100644 --- a/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -114,6 +114,30 @@ describeEachDialect("reference field lifecycle", (dialect) => { } }); + it("rejects creating a reference field whose target collection does not exist", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + + const res = await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "ghosts", multiple: true }, + }); + + expect(res.success).toBe(false); + if (!res.success) expect(res.error.code).toBe("COLLECTION_NOT_FOUND"); + + // The transaction rolls back, so neither the field nor its relation persists. + const field = await registry.getField("posts", "related"); + expect(field).toBeNull(); + } finally { + await teardownForDialect(ctx); + } + }); + it("PATCHes the relation's childLabel when the field's label is updated", async () => { ctx = await setupForDialect(dialect); try { From 4dad09b13477996686eda274cbae307001c7b599 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:00:32 +0300 Subject: [PATCH 23/26] Address review --- .../admin/src/components/ContentEditor.tsx | 10 +++++ .../src/components/ContentPickerModal.tsx | 45 ++++++++++++++++++- packages/core/src/api/schemas/content.ts | 5 +++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index acaf890b46..f556bfc077 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1006,6 +1006,7 @@ export function ContentEditor({ referenceState={referenceState} onReferenceChange={handleReferenceCurrentChange} onLoadMoreReferences={handleLoadMoreReferences} + entryLocale={entryLocale} /> ); return fieldEl; @@ -1267,6 +1268,8 @@ interface FieldRendererProps { onReferenceChange?: (group: string, rows: ReferenceEntryRow[]) => void; /** Page the rest of a relation's hydrated set. */ onLoadMoreReferences?: (group: string) => void; + /** Locale of the editing entry; threaded to reference pickers. */ + entryLocale?: string | null; } /** @@ -1286,6 +1289,7 @@ function FieldRenderer({ referenceState, onReferenceChange, onLoadMoreReferences, + entryLocale, }: FieldRendererProps) { const { t } = useLingui(); const pluginAdmins = usePluginAdmins(); @@ -1595,6 +1599,7 @@ function FieldRenderer({ state={referenceState?.[relationGroup]} onChange={(rows) => onReferenceChange?.(relationGroup, rows)} onLoadMore={() => onLoadMoreReferences?.(relationGroup)} + entryLocale={entryLocale} /> ); } @@ -1660,6 +1665,7 @@ function ReferenceFieldRenderer({ state, onChange, onLoadMore, + entryLocale, }: { label: string; labelClass?: string; @@ -1668,6 +1674,8 @@ function ReferenceFieldRenderer({ state?: ReferenceGroupState; onChange: (rows: ReferenceEntryRow[]) => void; onLoadMore: () => void; + /** Locale of the editing entry; scopes the picker to one variant per target. */ + entryLocale?: string | null; }) { const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); @@ -1708,6 +1716,7 @@ function ReferenceFieldRenderer({ id: p.id, slug: p.slug, title: p.title, + locale: p.locale, })); if (multiple) { const existing = new Set(rows.map((r) => r.id)); @@ -1828,6 +1837,7 @@ function ReferenceFieldRenderer({ multiple={multiple} selectedIds={selectedIds} onConfirm={handleConfirm} + locale={entryLocale ?? undefined} />
); diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 95ad8a11d3..486804b95b 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -34,6 +34,8 @@ export interface PickedContentEntry { id: string; slug: string | null; title: string; + /** Locale of the picked variant, so links/badges keep locale context before hydration. */ + locale?: string; } interface ContentPickerModalProps { @@ -52,6 +54,14 @@ interface ContentPickerModalProps { onConfirm: (rows: PickedContentEntry[]) => void; /** Optional dialog title override. */ title?: string; + /** + * The editing entry's locale (reference fields). When set, translations of the + * same entry collapse to one row, preferring this locale and falling back to + * another when the entry has no variant here — mirroring how the reference + * list resolves edges (`resolveEntries`/`pickVariant`). Edges are keyed by + * translation group, so a cross-locale target is still a valid pick. + */ + locale?: string; } function getItemTitle(item: { data: Record; slug: string | null; id: string }) { @@ -75,6 +85,7 @@ export function ContentPickerModal({ selectedIds = EMPTY_SELECTED, onConfirm, title, + locale, }: ContentPickerModalProps) { const { t } = useLingui(); const locked = !!collection; @@ -123,7 +134,30 @@ export function ContentPickerModal({ enabled: open && !!activeCollection, }); - const items = React.useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data]); + const items = React.useMemo(() => { + const flat = data?.pages.flatMap((page) => page.items) ?? []; + if (!locale) return flat; + // Reference fields link by translation group, so translations of the same + // entry are the same target. Collapse them to one row, preferring the + // editor locale and falling back to the lowest locale code (deterministic), + // mirroring `pickVariant` in the reference-list resolver. + const byGroup = new Map(); + const order: string[] = []; + for (const item of flat) { + const key = item.translationGroup ?? item.id; + const existing = byGroup.get(key); + if (!existing) { + byGroup.set(key, item); + order.push(key); + } else if ( + existing.locale !== locale && + (item.locale === locale || item.locale < existing.locale) + ) { + byGroup.set(key, item); + } + } + return order.map((key) => byGroup.get(key)!); + }, [data, locale]); const togglePicked = (item: ContentItem) => { setPicked((prev) => { @@ -136,6 +170,7 @@ export function ContentPickerModal({ id: item.id, slug: item.slug, title: getItemTitle(item), + locale: item.locale, }; } return next; @@ -144,7 +179,13 @@ export function ContentPickerModal({ const handleSingleChoose = (item: ContentItem) => { onConfirm([ - { collection: activeCollection, id: item.id, slug: item.slug, title: getItemTitle(item) }, + { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getItemTitle(item), + locale: item.locale, + }, ]); onOpenChange(false); }; diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index be3f194cb8..6309bf6421 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { bylineSummarySchema, bylineCreditSchema, contentBylineInputSchema } from "./bylines.js"; import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js"; +import { referenceChildrenResponseSchema } from "./relations.js"; // --------------------------------------------------------------------------- // Content: Input schemas @@ -177,6 +178,10 @@ export const contentItemSchema = z locale: z.string().nullable(), translationGroup: z.string().nullable(), seo: contentSeoSchema.optional(), + // First page of resolved children per reference field, keyed by the field's + // relation group. Only present when the editor GET path opts into hydration + // (`referenceOptions`); omitted otherwise, so it's optional here. + references: z.record(z.string(), referenceChildrenResponseSchema).optional(), }) .meta({ id: "ContentItem" }); From bd97d0a9f1a0429e41f0f452af5c4561c23625c1 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:13:24 +0300 Subject: [PATCH 24/26] Thread entry locale to prevent dedupe from shortcircuiting --- packages/admin/src/components/ContentEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index f556bfc077..8469155824 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1006,7 +1006,9 @@ export function ContentEditor({ referenceState={referenceState} onReferenceChange={handleReferenceCurrentChange} onLoadMoreReferences={handleLoadMoreReferences} - entryLocale={entryLocale} + // Existing entries carry their locale on `item`; new entries only + // have the URL-derived `entryLocale`. Mirror ContentSettingsPanel. + entryLocale={item?.locale ?? entryLocale} /> ); return fieldEl; From 8a5941a7881e75dfaea999e0856b71284bb1f542 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Tue, 21 Jul 2026 15:52:46 +0000 Subject: [PATCH 25/26] style: format --- packages/core/src/seed/apply.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index f4b4c287fe..57dc95a335 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -244,11 +244,7 @@ export async function applySeed( } const relationId = ulid(); - const relationName = allocateSeedRelationName( - collection.slug, - field.slug, - relationNames, - ); + const relationName = allocateSeedRelationName(collection.slug, field.slug, relationNames); pendingRelations.push({ id: relationId, name: relationName, From 8b1ff0c9df7418c9ab7d2bf572379ef7da609638 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:51:38 +0300 Subject: [PATCH 26/26] fix(core): keep seeded reference fields storage-less --- .../admin/src/components/ContentEditor.tsx | 2 +- packages/core/src/api/handlers/relations.ts | 3 +-- packages/core/src/schema/registry.ts | 2 ++ packages/core/tests/fields/reference.test.ts | 22 +++++++++++++++++++ .../integration/manifest-reference.test.ts | 6 ++--- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 948c910aed..84ac6737ac 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -346,7 +346,7 @@ export function ContentEditor({ const [bylinesTouched, setBylinesTouched] = React.useState(false); // Staged reference-field selections, keyed by relation translation group. - // Seeded from the hydrated first page (Task 7); the picker fills titles for + // Seeded from the hydrated first page; the picker fills titles for // newly added rows. Edges save inside the content payload — never via edge // POSTs. const [referenceState, setReferenceState] = React.useState>( diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index cc1643db37..c5b69499ab 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -373,8 +373,7 @@ export async function handleReferenceChildrenGet( * Resolve a relation + parent entry + child ids and replace the parent's * children under that relation. Extracted from `handleReferenceChildrenSet` so * the content create/update transaction can reuse the same resolution logic - * (see Task 6) — `db` accepts a `Kysely` or a `Transaction` - * (assignable to `Kysely` for query building). + * with either a `Kysely` or a `Transaction`. * * Returns the resolved relation/entry translation_groups on success so callers * can re-read and echo the new set without re-deriving them. diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index e40d207ad4..6aa57c2bf3 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -951,6 +951,8 @@ export class SchemaRegistry { .addColumn("translation_group", "text"); 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) => { diff --git a/packages/core/tests/fields/reference.test.ts b/packages/core/tests/fields/reference.test.ts index 16505499e6..9c4708925e 100644 --- a/packages/core/tests/fields/reference.test.ts +++ b/packages/core/tests/fields/reference.test.ts @@ -97,6 +97,28 @@ describeEachDialect("reference field is storage-less in the registry", (dialect) expect(await registry.getField("posts", "related")).toBeNull(); }); + it("creates seeded reference fields without adding columns", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createSeedCollection( + { slug: "seeded_posts", label: "Seeded posts", labelSingular: "Seeded post" }, + [ + { slug: "title", label: "Title", type: "string" }, + { + slug: "related", + label: "Related", + type: "reference", + validation: { relation: "grp_x", targetCollection: "posts", multiple: true }, + }, + ], + ); + + const table = (await ctx.db.introspection.getTables()).find( + (candidate) => candidate.name === "ec_seeded_posts", + ); + expect(table?.columns.map((column) => column.name)).toContain("title"); + expect(table?.columns.map((column) => column.name)).not.toContain("related"); + }); + it("rejects changing a field to or from reference", async () => { const registry = new SchemaRegistry(ctx.db); await registry.createField("posts", { slug: "title2", label: "Title2", type: "string" }); diff --git a/packages/core/tests/integration/manifest-reference.test.ts b/packages/core/tests/integration/manifest-reference.test.ts index 047c18df39..0ed8e60888 100644 --- a/packages/core/tests/integration/manifest-reference.test.ts +++ b/packages/core/tests/integration/manifest-reference.test.ts @@ -1,9 +1,7 @@ /** * `_buildManifest` copies `field.validation` into the manifest descriptor - * for `repeater`/`file`/`image` fields, but not `reference` fields. That - * means the admin editor receives `kind: "reference"` (from - * `FIELD_TYPE_TO_KIND`) but no `relation` / `targetCollection` / `multiple` - * config to drive the reference picker widget. + * for reference fields so the admin editor receives the relation, + * target collection, and cardinality needed by the reference picker. */ import type { Kysely } from "kysely";