From 5c56e5656ce386887f1bc61b10d5679157f1fca9 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:42:33 +0300 Subject: [PATCH 1/2] feat(core): make reference fields storage-less A reference field's selections are edges in _emdash_content_references, not a column on the collection's table. The registry skips column DDL for storage-less field types, the schema handlers own the backing relation's lifecycle (created with the field, destroyed with it, target collection immutable), and the previously unregistered relation and reference-edge routes are wired into injectCoreRoutes. A storage-less field never appears in `data` in either direction: it is excluded from the generated Zod shape (so a required reference field is satisfiable at all), rejected with a VALIDATION_ERROR when a caller sends one, and filtered out of reads so a column left behind by an older version cannot round-trip back into a save. That replaces the reference-target existence pass in validateContentData, which validated a column-backed value that no longer exists. Seeds apply a reference field's $ref: value as an edge, so seed files keep working unchanged. Co-Authored-By: Claude Opus 5 --- .changeset/storageless-reference-fields.md | 5 + packages/core/src/api/handlers/content.ts | 43 ++ packages/core/src/api/handlers/relations.ts | 158 +++++-- packages/core/src/api/handlers/schema.ts | 190 +++++++- packages/core/src/api/handlers/validation.ts | 121 +---- packages/core/src/api/schemas/relations.ts | 6 + packages/core/src/api/schemas/schema.ts | 5 + packages/core/src/astro/integration/routes.ts | 27 ++ packages/core/src/database/transaction.ts | 13 + packages/core/src/emdash-runtime.ts | 11 +- packages/core/src/schema/registry.ts | 63 ++- packages/core/src/schema/types.ts | 14 + packages/core/src/schema/zod-generator.ts | 7 + packages/core/src/seed/apply.ts | 320 +++++++++++-- packages/core/tests/fields/reference.test.ts | 91 +++- .../content/reference-data-contract.test.ts | 126 ++++++ .../integration/manifest-reference.test.ts | 99 ++++ .../tests/integration/mcp/validation.test.ts | 83 ++-- .../schema/reference-field-lifecycle.test.ts | 428 ++++++++++++++++++ .../unit/api/field-validation-schema.test.ts | 17 + packages/core/tests/unit/astro/routes.test.ts | 17 + packages/core/tests/unit/seed/apply.test.ts | 98 +++- 22 files changed, 1666 insertions(+), 276 deletions(-) create mode 100644 .changeset/storageless-reference-fields.md create mode 100644 packages/core/tests/integration/content/reference-data-contract.test.ts create mode 100644 packages/core/tests/integration/manifest-reference.test.ts create mode 100644 packages/core/tests/integration/schema/reference-field-lifecycle.test.ts diff --git a/.changeset/storageless-reference-fields.md b/.changeset/storageless-reference-fields.md new file mode 100644 index 0000000000..c7de488140 --- /dev/null +++ b/.changeset/storageless-reference-fields.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Reference fields no longer store a value on the collection's table — their selections are content-reference edges, created and removed with the field's relation. Set them through an entry's reference endpoints; sending one under `data` is now a validation error, and reads no longer return it. **Breaking:** values that an older version wrote into a reference field's column are no longer read or written, so re-select them; the column itself is left in place untouched. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 9ee11a6a4b..89e29c2670 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -36,6 +36,8 @@ import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled, resolveConfiguredLocale } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; +import { requestCached } from "../../request-cache.js"; +import { STORAGELESS_FIELD_TYPES } from "../../schema/types.js"; import { FTSManager } from "../../search/fts-manager.js"; import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingColumnError, isMissingTableError } from "../../utils/db-errors.js"; @@ -90,6 +92,43 @@ async function collectionHasSeo(db: Kysely, collection: string): Promi return row?.has_seo === 1; } +/** + * Field slugs on `collection` that persist no column, so no value of theirs + * belongs in an entry's `data` (see `STORAGELESS_FIELD_TYPES`). Memoized for the + * request: a single read hits this once per collection however many entries it + * covers. + */ +function storagelessFieldSlugs(db: Kysely, collection: string): Promise> { + return requestCached(`storageless-fields:${collection}`, async () => { + const rows = await db + .selectFrom("_emdash_fields") + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select("_emdash_fields.slug") + .where("_emdash_collections.slug", "=", collection) + .where("_emdash_fields.type", "in", [...STORAGELESS_FIELD_TYPES]) + .execute(); + return new Set(rows.map((row) => row.slug)); + }); +} + +/** + * Drop storage-less keys from an entry's `data`. + * + * A reference field created before the type became storage-less still has its + * column, and the row mapper turns every column into a `data` key. Returning one + * would hand the caller a value the write path rejects, which the admin's + * re-send-what-it-loaded autosave would then bounce straight back. + */ +async function stripStoragelessFromItem( + db: Kysely, + collection: string, + item: ContentItem, +): Promise { + const slugs = await storagelessFieldSlugs(db, collection); + if (slugs.size === 0) return; + for (const slug of slugs) delete item.data[slug]; +} + async function collectionSupportsRevisions( db: Kysely, collection: string, @@ -704,6 +743,8 @@ export async function handleContentGet( }; } + await stripStoragelessFromItem(db, collection, item); + // Hydrate SEO data if the collection has SEO enabled const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); @@ -753,6 +794,8 @@ export async function handleContentGetIncludingTrashed( }; } + await stripStoragelessFromItem(db, collection, item); + // Hydrate SEO data if the collection has SEO enabled const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 3581ae1696..6578468957 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -11,6 +11,7 @@ import { InvalidCursorError } from "../../database/repositories/types.js"; import type { ContentItem } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; import { resolveConfiguredLocale } from "../../i18n/config.js"; +import { requestCached } from "../../request-cache.js"; import { SchemaRegistry } from "../../schema/registry.js"; import type { ApiResult } from "../types.js"; @@ -228,11 +229,56 @@ export type EntryRef = { id: string; slug: string | null; collection: string; + /** + * Display label sourced from the collection's configured `titleField`, then + * `title`, then `name` — `null` when none is set, leaving the client to fall + * back to slug/id. Mirrors the admin's `getEntryTitle`. + */ + title: string | null; /** The actual locale of the resolved variant — see `pickVariant`. */ locale: string | null; + /** + * The edge's target: the translation group `id` was resolved from. Stable + * across locales, unlike `id`, so callers comparing a ref against a content + * row (the admin's picker) match the entry rather than one of its variants. + */ + translationGroup: string | null; sortOrder?: number; }; +/** + * Display title for a resolved entry: the collection's configured `titleField`, + * then `title`, then `name`, else null. + */ +function entryTitle(data: Record, titleField?: string): string | null { + if (titleField) { + const configured = data[titleField]; + if (typeof configured === "string" && configured.length > 0) return configured; + } + 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; +} + +/** + * The collection's configured `titleField`, memoized for the request: a single + * content read hydrates every reference field, and several of them commonly + * target the same collection. + */ +export async function getReferenceTitleField( + db: Kysely, + collection: string, +): Promise { + return requestCached(`reference-title-field:${collection}`, async () => { + const row = await db + .selectFrom("_emdash_collections") + .select("title_field") + .where("slug", "=", collection) + .executeTakeFirst(); + return row?.title_field ?? undefined; + }); +} + /** Resolve a relation from an id OR its translation_group. */ async function resolveRelation( repo: RelationRepository, @@ -275,6 +321,7 @@ async function resolveEntries( pick: (e: ContentReference) => string, locale: string | null, includeDrafts: boolean, + titleField?: string, ): Promise { const groups = edges.map(pick); const all = await content.findTranslationsForGroups(collection, groups, { @@ -301,7 +348,9 @@ async function resolveEntries( id: entry.id, slug: entry.slug, collection, + title: entryTitle(entry.data, titleField), locale: entry.locale, + translationGroup: entry.translationGroup, sortOrder: edge.sortOrder, }); } @@ -352,6 +401,7 @@ export async function handleReferenceChildrenGet( (e) => e.childGroup, entry.locale, includeDrafts, + await getReferenceTitleField(db, rel.childCollection), ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch (error) { @@ -359,6 +409,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 + * other writers of the same edges (the seed engine) share one resolution path, + * and so it can run against 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. + */ +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, @@ -367,53 +481,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, @@ -421,6 +509,7 @@ export async function handleReferenceChildrenSet( (e) => e.childGroup, entry.locale, true, + await getReferenceTitleField(db, rel.childCollection), ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch { @@ -471,6 +560,7 @@ export async function handleReferenceParentsGet( (e) => e.parentGroup, entry.locale, includeDrafts, + await getReferenceTitleField(db, rel.parentCollection), ); return { success: true, data: { parents, nextCursor: edges.nextCursor } }; } catch (error) { diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 8c3496aaa2..bdc12c3e4e 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 { @@ -20,6 +22,65 @@ 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 constraint failed") || message.includes("duplicate key"); +} + +/** + * 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. + */ +export 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"); + } + + 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}`; + 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"); +} + function invalidateFieldCaches(collectionSlug: string): void { invalidateCollectionCache(collectionSlug); invalidateSchemaCache(collectionSlug); @@ -319,6 +380,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); @@ -360,6 +464,70 @@ 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", + }, + }; + } + + // `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, updateInput); + + 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); + for (const sibling of siblings) { + await relations.update(sibling.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); @@ -399,8 +567,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); + } invalidateFieldCaches(collectionSlug); diff --git a/packages/core/src/api/handlers/validation.ts b/packages/core/src/api/handlers/validation.ts index 5d8fabea80..121d5f51c6 100644 --- a/packages/core/src/api/handlers/validation.ts +++ b/packages/core/src/api/handlers/validation.ts @@ -6,51 +6,24 @@ * * - required fields must be present and non-empty * - select / multiSelect values must match the configured options - * - reference fields must resolve to a real, non-trashed target + * - storage-less fields (reference) must not be sent in `data` at all * * Errors surface as `{ code: "VALIDATION_ERROR", message }` with all * offending fields listed in one message so callers can fix everything in * a single round trip. */ -import { sql, type Kysely } from "kysely"; +import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; -import { validateIdentifier } from "../../database/validate.js"; import { SchemaRegistry } from "../../schema/registry.js"; -import type { Field } from "../../schema/types.js"; +import { STORAGELESS_FIELD_TYPES } from "../../schema/types.js"; import { generateZodSchema } from "../../schema/zod-generator.js"; -import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; -import { isMissingTableError } from "../../utils/db-errors.js"; type ValidationResult = | { ok: true } | { ok: false; error: { code: "VALIDATION_ERROR" | "COLLECTION_NOT_FOUND"; message: string } }; -/** Treat `undefined`, `null`, and `""` as "not set". */ -function isMissing(value: unknown): boolean { - return value === undefined || value === null || value === ""; -} - -/** - * Resolve the target collection slug for a reference field. - * - * Schema-defined reference fields (the static `reference()` factory in - * `fields/reference.ts`) put the target in `options.collection`. The MCP - * `schema_create_field` tool also puts it there. Tests and some admin paths - * stash it inside `validation.collection` directly; we accept both. - */ -function getReferenceTargetCollection(field: Field): string | undefined { - const fromOptions = field.options?.collection; - if (typeof fromOptions === "string" && fromOptions.length > 0) return fromOptions; - const validation = field.validation; - if (validation && "collection" in validation) { - const fromValidation: unknown = (validation as { collection?: unknown }).collection; - if (typeof fromValidation === "string" && fromValidation.length > 0) return fromValidation; - } - return undefined; -} - /** * Format a Zod issue path into a human-readable field reference, e.g. * `tags`, `tags.1`, `image.alt`. @@ -93,8 +66,20 @@ export async function validateContentData( // `_rev`) are reserved for internal handler/runtime use and aren't real // fields; skip them. const knownFields = new Set(collectionWithFields.fields.map((f) => f.slug)); + const storagelessFields = new Set( + collectionWithFields.fields + .filter((f) => STORAGELESS_FIELD_TYPES.has(f.type)) + .map((f) => f.slug), + ); for (const key of Object.keys(data)) { if (key.startsWith("_")) continue; + if (storagelessFields.has(key)) { + // A storage-less field holds no value in `data` — a reference field's + // selections are edges. Accepting the key here would validate a value + // the write path then has nowhere to put. + issues.push(`${key}: set this field through the entry's references endpoint, not 'data'`); + continue; + } if (!knownFields.has(key)) { issues.push(`${key}: unknown field on collection '${collection}'`); } @@ -125,82 +110,6 @@ export async function validateContentData( } } - // Reference target existence. Only check fields that: - // - have a value (non-missing) in `data` - // - have a resolvable target collection - // - in partial mode: are present in `data` - // Batch one IN-query per target collection to keep round-trips low. - const refsByTarget = new Map(); - for (const field of collectionWithFields.fields) { - if (field.type !== "reference") continue; - if (options.partial && !Object.hasOwn(data, field.slug)) continue; - const value = data[field.slug]; - if (isMissing(value)) continue; - if (typeof value !== "string") continue; // Zod will have flagged this already - const target = getReferenceTargetCollection(field); - if (!target) continue; - const list = refsByTarget.get(target) ?? []; - list.push({ field: field.slug, id: value }); - refsByTarget.set(target, list); - } - - for (const [target, refs] of refsByTarget) { - // Validate the target collection slug before interpolating into raw - // SQL — defense-in-depth even though slugs are already validated at - // schema-create time. - try { - validateIdentifier(target, "reference target collection"); - } catch { - for (const ref of refs) { - issues.push(`${ref.field}: invalid reference target collection '${target}'`); - } - continue; - } - - const ids = [...new Set(refs.map((r) => r.id))]; - const tableName = `ec_${target}`; - - // Chunk the IN clause to stay below D1's bind-parameter limit. One - // reference per request is the common case today; chunking makes the - // helper safe if a future multiSelect-of-references is added. - const found = new Set(); - let targetTableMissing = false; - for (const idChunk of chunks(ids, SQL_BATCH_SIZE)) { - try { - const rows = await sql<{ id: string }>` - SELECT id FROM ${sql.ref(tableName)} - WHERE id IN (${sql.join(idChunk)}) - AND deleted_at IS NULL - `.execute(db); - for (const row of rows.rows) { - found.add(row.id); - } - } catch (error) { - // Missing table = the target collection table doesn't exist - // (orphan reference). Treat all those references as missing. - // Any other DB error (permissions, connection, syntax) must - // propagate — silently dropping data integrity errors as - // "not found" is exactly the bug F5 fixes. - if (isMissingTableError(error)) { - targetTableMissing = true; - break; - } - throw error; - } - } - if (targetTableMissing) { - for (const ref of refs) { - issues.push(`${ref.field}: target '${ref.id}' not found in collection '${target}'`); - } - continue; - } - for (const ref of refs) { - if (!found.has(ref.id)) { - issues.push(`${ref.field}: target '${ref.id}' not found in collection '${target}'`); - } - } - } - if (issues.length === 0) return { ok: true }; return { ok: false, diff --git a/packages/core/src/api/schemas/relations.ts b/packages/core/src/api/schemas/relations.ts index 13a16948e6..be981ffff6 100644 --- a/packages/core/src/api/schemas/relations.ts +++ b/packages/core/src/api/schemas/relations.ts @@ -93,11 +93,17 @@ 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 // presenting a wrong-locale entry under the requested context. locale: z.string().nullable(), + // The translation group the ref resolved from — the locale-stable identity + // of the referenced entry, which `id` is not. + translationGroup: z.string().nullable(), sortOrder: z.number().int().optional(), }) .meta({ id: "ReferenceEntryRef" }); diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index b9f4d9ff80..16fefec071 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -107,6 +107,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(), }) .superRefine((validation, ctx) => { for (const [minimum, maximum] of [ diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index be522628cd..9228db1e1b 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]", @@ -451,6 +462,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/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/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ce80009885..846859a0ca 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2521,10 +2521,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/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 3f2a6efa3e..21b5ebee0e 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -9,7 +9,12 @@ import type { import { sql } from "kysely"; import { ulid } from "ulidx"; -import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js"; +import { + columnExists, + currentTimestamp, + listTablesLike, + tableExists, +} from "../database/dialect-helpers.js"; import { withTransaction } from "../database/transaction.js"; import type { CollectionTable, Database, FieldTable } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; @@ -48,6 +53,7 @@ import { isIndexableFieldType, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS, + STORAGELESS_FIELD_TYPES, } from "./types.js"; // Regex patterns for schema registry @@ -955,17 +961,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, + ); + } if (input.indexed) { await this.createFieldIndex(collectionSlug, id, input.slug, trx); @@ -1054,6 +1065,17 @@ export class SchemaRegistry { let nextType = field.type; 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( @@ -1296,8 +1318,19 @@ export class SchemaRegistry { await this.dropFieldIndex(field.id, 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. + // Whether a field is storage-less is a property of the row rather than of + // its type: reference fields created before they became storage-less + // still carry a column, and skipping the DDL would strand it and block + // the slug from ever being reused. + const hasColumn = await columnExists( + trx, + this.getTableName(collectionSlug), + this.getColumnName(fieldSlug), + ); + if (hasColumn) { + await this.dropColumn(collectionSlug, fieldSlug, trx); + } }); if (activeCoverageInvalidated) { await invalidateContentMediaUsageSchemaChange(this.db, collectionSlug); @@ -1434,6 +1467,8 @@ export class SchemaRegistry { if (options.ifNotExists) table = table.ifNotExists(); 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/src/schema/types.ts b/packages/core/src/schema/types.ts index b5a517e336..e5a7523748 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -92,6 +92,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 */ @@ -159,6 +167,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/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 84ad13d566..f8eb9d5b0d 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -2,6 +2,7 @@ import { z, type ZodTypeAny } from "zod"; import { hashString } from "../utils/hash.js"; import type { CollectionWithFields, Field, FieldType, RepeaterSubField } from "./types.js"; +import { STORAGELESS_FIELD_TYPES } from "./types.js"; /** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */ const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/; @@ -11,6 +12,11 @@ const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/; * * This allows runtime validation of content based on dynamically * defined schemas stored in D1. + * + * Storage-less fields are omitted: they hold no value in `data` (a reference + * field's selections are edges), so a shape entry for one would demand a value + * that has nowhere to come from — a `required` reference field could never be + * satisfied. */ export function generateZodSchema( collection: CollectionWithFields, @@ -18,6 +24,7 @@ export function generateZodSchema( const shape: Record = {}; for (const field of collection.fields) { + if (STORAGELESS_FIELD_TYPES.has(field.type)) continue; shape[field.slug] = generateFieldSchema(field); } diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 8dd24d3770..5cfa186097 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"; @@ -22,12 +24,15 @@ import type { MediaValue } from "../fields/types.js"; import { getI18nConfig, resolveConfiguredLocale } 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 { SchemaError, 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 { chunks } from "../utils/chunks.js"; import type { SeedFile, + SeedField, SeedApplyOptions, SeedApplyResult, SeedCollection, @@ -54,6 +59,8 @@ async function applyDisplayDateFields( } const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; +const SEED_RELATION_NAME_MAX_ATTEMPTS = 5; +const SEED_RELATION_INSERT_BATCH_SIZE = 10; import { validateSeed } from "./validate.js"; /** Pattern to remove file extensions */ @@ -177,6 +184,20 @@ export async function applySeed( // 2-3. Collections and Fields if (seed.collections) { const registry = new SchemaRegistry(db); + const seedCollectionSlugs = new Set(seed.collections.map((collection) => collection.slug)); + const relationNames = new Set( + (await db.selectFrom("_emdash_relations").select("name").execute()).map((row) => row.name), + ); + const externalTargetExists = new Map(); + const pendingRelations: Array<{ + id: string; + name: string; + parent_collection: string; + child_collection: string; + parent_label: string; + child_label: string; + translation_group: string; + }> = []; for (const collection of seed.collections) { // Check if collection exists @@ -205,36 +226,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, - indexed: field.indexed || 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, - indexed: field.indexed || 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++; } // Second write: display/date fields, now that fields exist. @@ -248,19 +242,57 @@ export async function applySeed( continue; } - const fields = collection.fields.map((field) => ({ - slug: field.slug, - label: field.label, - type: field.type, - required: field.required || false, - unique: field.unique || false, - searchable: field.searchable || false, - indexed: field.indexed || false, - defaultValue: field.defaultValue, - validation: field.validation, - widget: field.widget, - options: field.options, - })); + const fields = []; + for (const field of collection.fields) { + let fieldValidation = field.validation; + const targetCollection = + field.type === "reference" && typeof fieldValidation?.targetCollection === "string" + ? fieldValidation.targetCollection + : undefined; + if (targetCollection) { + let targetExists = seedCollectionSlugs.has(targetCollection); + if (!targetExists) { + targetExists = externalTargetExists.get(targetCollection) ?? false; + if (!externalTargetExists.has(targetCollection)) { + targetExists = Boolean(await registry.getCollection(targetCollection)); + externalTargetExists.set(targetCollection, targetExists); + } + } + if (!targetExists) { + throw new SchemaError( + `Target collection "${targetCollection}" not found`, + "COLLECTION_NOT_FOUND", + ); + } + + const relationId = ulid(); + const relationName = allocateSeedRelationName(collection.slug, field.slug, relationNames); + pendingRelations.push({ + id: relationId, + name: relationName, + parent_collection: collection.slug, + child_collection: targetCollection, + parent_label: collection.labelSingular ?? collection.label, + child_label: field.label, + translation_group: relationId, + }); + fieldValidation = { ...fieldValidation, relation: relationId }; + } + + fields.push({ + slug: field.slug, + label: field.label, + type: field.type, + required: field.required || false, + unique: field.unique || false, + searchable: field.searchable || false, + indexed: field.indexed || false, + defaultValue: field.defaultValue, + validation: fieldValidation, + widget: field.widget, + options: field.options, + }); + } // Create a fresh seed schema in bulk to stay within D1's query budget. await registry.createSeedCollection( @@ -283,7 +315,11 @@ export async function applySeed( // the schema exists. await applyDisplayDateFields(registry, collection); result.collections.created++; - result.fields.created += fields.length; + result.fields.created += collection.fields.length; + } + + for (const relationBatch of chunks(pendingRelations, SEED_RELATION_INSERT_BATCH_SIZE)) { + await db.insertInto("_emdash_relations").values(relationBatch).execute(); } } @@ -515,6 +551,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"; @@ -527,7 +570,7 @@ export async function applySeed( await trxContentRepo.update(collectionSlug, existing.id, { status, - data: resolvedData, + data: columnData, }); contentMutated = true; @@ -540,6 +583,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" @@ -552,7 +596,7 @@ export async function applySeed( const draft = await trxRevisionRepo.create({ collection: collectionSlug, entryId: existing.id, - data: resolvedData, + data: columnData, }); try { await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id); @@ -593,6 +637,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; @@ -620,7 +671,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, @@ -635,6 +686,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" @@ -900,6 +952,23 @@ export async function applySeed( return result; } +function allocateSeedRelationName( + collectionSlug: string, + fieldSlug: string, + usedNames: Set, +): string { + const baseName = `${collectionSlug}_${fieldSlug}`.slice(0, 63); + for (let attempt = 0; attempt < SEED_RELATION_NAME_MAX_ATTEMPTS; attempt++) { + const suffix = attempt === 0 ? "" : `_${attempt + 1}`; + const name = attempt === 0 ? baseName : `${baseName.slice(0, 63 - suffix.length)}${suffix}`; + if (!usedNames.has(name)) { + usedNames.add(name); + return name; + } + } + throw new SchemaError("Could not allocate a unique relation name", "RELATION_NAME_CONFLICT"); +} + /** * Apply hierarchical taxonomy terms (parents before children) */ @@ -1029,6 +1098,161 @@ 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, + indexed: field.indexed || 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, + indexed: field.indexed || 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/fields/reference.test.ts b/packages/core/tests/fields/reference.test.ts index 78095ffb83..9c4708925e 100644 --- a/packages/core/tests/fields/reference.test.ts +++ b/packages/core/tests/fields/reference.test.ts @@ -1,6 +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", () => { @@ -38,3 +47,83 @@ 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"); + }); +}); + +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("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" }); + await expect( + registry.updateField("posts", "title2", { type: "reference" }), + ).rejects.toMatchObject({ code: "FIELD_TYPE_COLUMN_CHANGE" }); + }); +}); diff --git a/packages/core/tests/integration/content/reference-data-contract.test.ts b/packages/core/tests/integration/content/reference-data-contract.test.ts new file mode 100644 index 0000000000..efb49111f1 --- /dev/null +++ b/packages/core/tests/integration/content/reference-data-contract.test.ts @@ -0,0 +1,126 @@ +import { sql } from "kysely"; +import { it, expect, beforeEach, afterEach } from "vitest"; + +import { handleContentGet } from "../../../src/api/handlers/content.js"; +import { validateContentData } from "../../../src/api/handlers/validation.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("storage-less fields are absent from content `data`", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "pages", label: "Pages", labelSingular: "Page" }); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { + slug: "title", + label: "Title", + type: "string", + required: true, + }); + await registry.createField("posts", { + slug: "parent_page", + label: "Parent Page", + type: "reference", + required: true, + validation: { relation: "grp_parent_page", targetCollection: "pages" }, + }); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("does not require a value in `data` for a required reference field", async () => { + const result = await validateContentData( + ctx.db, + "posts", + { title: "A post" }, + { partial: false }, + ); + + expect(result).toEqual({ ok: true }); + }); + + it("rejects a reference field sent in `data`, naming the key that replaces it", async () => { + const result = await validateContentData( + ctx.db, + "posts", + { title: "A post", parent_page: "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, + { partial: false }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + expect(result.error.message).toContain("parent_page"); + expect(result.error.message).toContain("references"); + }); + + it("rejects a reference field sent in `data` on a partial update too", async () => { + const result = await validateContentData( + ctx.db, + "posts", + { parent_page: "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, + { partial: true }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toContain("parent_page"); + }); + + it("rejects a reference field sent as null in `data`", async () => { + // The admin's autosave re-sends what it loaded. A legacy entry whose + // reference column was dropped by migration 070 must not be able to + // smuggle the key back in as an empty value. + const result = await validateContentData( + ctx.db, + "posts", + { title: "A post", parent_page: null }, + { partial: false }, + ); + + expect(result.ok).toBe(false); + }); + + it("does not return a legacy reference column in `data`", async () => { + // A reference field created before the type became storage-less still has + // its column. Nothing writes it any more, but a read that surfaced it + // would feed the value straight back into a save the write path now + // rejects. Simulate that install by adding the column by hand. + await sql`ALTER TABLE ${sql.ref("ec_posts")} ADD COLUMN ${sql.ref("parent_page")} TEXT`.execute( + ctx.db, + ); + const repo = new ContentRepository(ctx.db); + const created = await repo.create({ type: "posts", data: { title: "A post" }, slug: "a-post" }); + await sql` + UPDATE ${sql.ref("ec_posts")} + SET ${sql.ref("parent_page")} = ${"01ARZ3NDEKTSV4RRFFQ69G5FAV"} + WHERE id = ${created.id} + `.execute(ctx.db); + + const result = await handleContentGet(ctx.db, "posts", created.id); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.item.data).not.toHaveProperty("parent_page"); + expect(result.data.item.data.title).toBe("A post"); + }); + + it("still requires column-backed fields", async () => { + const result = await validateContentData(ctx.db, "posts", {}, { partial: false }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toContain("title"); + }); +}); 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..0ed8e60888 --- /dev/null +++ b/packages/core/tests/integration/manifest-reference.test.ts @@ -0,0 +1,99 @@ +/** + * `_buildManifest` copies `field.validation` into the manifest descriptor + * for reference fields so the admin editor receives the relation, + * target collection, and cardinality needed by the reference picker. + */ + +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, + }); + }); +}); diff --git a/packages/core/tests/integration/mcp/validation.test.ts b/packages/core/tests/integration/mcp/validation.test.ts index e19aae3e9c..d30956ad94 100644 --- a/packages/core/tests/integration/mcp/validation.test.ts +++ b/packages/core/tests/integration/mcp/validation.test.ts @@ -19,7 +19,6 @@ import { Role } from "@emdash-cms/auth"; import type { Kysely } from "kysely"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ContentRepository } from "../../../src/database/repositories/content.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { connectMcpHarness, extractText, type McpHarness } from "../../utils/mcp-runtime.js"; @@ -293,27 +292,7 @@ describe("MCP validation — reference field targets (bug #6)", () => { await teardownTestDatabase(db); }); - it("rejects reference to non-existent target id", async () => { - const result = await harness.client.callTool({ - name: "content_create", - arguments: { - collection: "post", - data: { title: "T", parent_page: "01NOTAREALPAGE" }, - }, - }); - expect(result.isError).toBe(true); - const text = extractText(result); - expect(text).toMatch(VALIDATION_ERROR); - // Tight match: the error must specifically mention the offending field, - // echo the bad target id, AND say "not found" (one assertion per - // concern so a regression where any signal disappears is caught). - expect(text).toContain("parent_page"); - expect(text).toContain("01NOTAREALPAGE"); - expect(text).toMatch(/\bnot found\b/i); - }); - - it("accepts reference to a real target id (regression guard)", async () => { - // Create a page first + it("rejects a reference sent in `data`, whatever the target", async () => { const page = await harness.client.callTool({ name: "content_create", arguments: { collection: "page", data: { title: "Real page" } }, @@ -328,52 +307,52 @@ describe("MCP validation — reference field targets (bug #6)", () => { data: { title: "T", parent_page: pageId }, }, }); - expect(post.isError, extractText(post)).toBeFalsy(); - }); - it("rejects reference to id that exists in a different collection", async () => { - // Create a post (which is NOT the page collection the reference is scoped to) - const repo = new ContentRepository(db); - const otherPost = await repo.create({ - type: "post", - data: { title: "Other" }, - slug: "other", - status: "draft", - authorId: ADMIN_ID, - }); + expect(post.isError).toBe(true); + const text = extractText(post); + expect(text).toMatch(VALIDATION_ERROR); + // The error has to name the offending field and the key that replaces + // it, or a caller has no way to work out what to send instead. + expect(text).toContain("parent_page"); + expect(text).toContain("references"); + }); + it("rejects a reference to a non-existent target the same way", async () => { const result = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", - data: { title: "T", parent_page: otherPost.id }, + data: { title: "T", parent_page: "01NOTAREALPAGE" }, }, }); - // Reference points to a post id but field expects a page reference. - // After fix this should fail. + expect(result.isError).toBe(true); - expect(extractText(result)).toMatch(VALIDATION_ERROR); + expect(extractText(result)).toContain("parent_page"); }); - it("rejects reference to a soft-deleted (trashed) target", async () => { - const page = await harness.client.callTool({ + it("creates an entry that omits the reference field", async () => { + const result = await harness.client.callTool({ name: "content_create", - arguments: { collection: "page", data: { title: "Will be trashed" } }, + arguments: { collection: "post", data: { title: "T" } }, }); - const pageId = JSON.parse(extractText(page)).item.id as string; - // Trash via repo - const repo = new ContentRepository(db); - await repo.delete("page", pageId); - const result = await harness.client.callTool({ + expect(result.isError, extractText(result)).toBeFalsy(); + }); + + it("does not return the reference field in `data` on read", async () => { + const created = await harness.client.callTool({ name: "content_create", - arguments: { - collection: "post", - data: { title: "T", parent_page: pageId }, - }, + arguments: { collection: "post", data: { title: "T" } }, }); - expect(result.isError).toBe(true); - expect(extractText(result)).toMatch(VALIDATION_ERROR); + const postId = JSON.parse(extractText(created)).item.id as string; + + const read = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id: postId }, + }); + + expect(read.isError, extractText(read)).toBeFalsy(); + expect(JSON.parse(extractText(read)).item.data).not.toHaveProperty("parent_page"); }); }); 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..b68c475a7a --- /dev/null +++ b/packages/core/tests/integration/schema/reference-field-lifecycle.test.ts @@ -0,0 +1,428 @@ +import { sql } from "kysely"; +import { expect, it } from "vitest"; + +import { + handleSchemaFieldCreate, + handleSchemaFieldDelete, + handleSchemaFieldUpdate, +} from "../../../src/api/handlers/schema.js"; +import { columnExists } from "../../../src/database/dialect-helpers.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("drops the column of a reference field that predates storage-less references", async () => { + ctx = await setupForDialect(dialect); + try { + const registry = new SchemaRegistry(ctx.db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + // Reference fields used to be column-backed. Create one as `string` so + // the column DDL runs, then relabel it to reproduce that row exactly. + await registry.createField("posts", { slug: "related", label: "Related", type: "string" }); + await sql`UPDATE _emdash_fields SET type = 'reference' WHERE slug = 'related'`.execute( + ctx.db, + ); + + await registry.deleteField("posts", "related"); + + expect(await columnExists(ctx.db, "ec_posts", "related")).toBe(false); + } finally { + await teardownForDialect(ctx); + } + }); + + it("leaves no column behind when a storage-less 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" }); + await handleSchemaFieldCreate(ctx.db, "posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { targetCollection: "posts", multiple: true }, + }); + + await registry.deleteField("posts", "related"); + + expect(await columnExists(ctx.db, "ec_posts", "related")).toBe(false); + } 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("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 { + 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("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 { + 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 { + 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); + } + }); +}); 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 52fbef6c8b..b24781b0ed 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -87,6 +87,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(); diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 67df1e9e09..8729c8457b 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -13,6 +13,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"; @@ -116,6 +117,55 @@ describe("applySeed", () => { expect(tableInfo.rows.map((column) => column.name)).toContain("field_73"); }); + it("creates reference fields that target a later seed collection", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [ + { + slug: "author", + label: "Author", + type: "reference", + validation: { targetCollection: "authors" }, + }, + ], + }, + { slug: "authors", label: "Authors", fields: [] }, + ], + }; + + await applySeed(db, seed); + + const field = await new SchemaRegistry(db).getField("posts", "author"); + const relation = await new RelationRepository(db).findByName("posts_author"); + expect(relation?.childCollection).toBe("authors"); + expect(field?.validation?.relation).toBe(relation?.translationGroup); + }); + + it("creates reference-heavy schemas within the D1 query budget", async () => { + const counter = new QueryCountingPlugin(); + const fields = Array.from({ length: 20 }, (_, index) => ({ + slug: `related_${index}`, + label: `Related ${index}`, + type: "reference" as const, + validation: { targetCollection: "posts" }, + })); + const seed: SeedFile = { + version: "1", + collections: [{ slug: "posts", label: "Posts", fields }], + }; + + const result = await applySeed(db.withPlugin(counter), seed); + + expect(result.fields.created).toBe(20); + expect(counter.count).toBeLessThan(50); + const relations = await new RelationRepository(db).list(); + expect(relations).toHaveLength(20); + }); + it("should create collections and fields", async () => { const seed: SeedFile = { version: "1", @@ -1081,22 +1131,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" } }, @@ -1117,8 +1173,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 a3791bc4b1ebfdeb029e001af039ffc9876e1367 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:56:56 +0300 Subject: [PATCH 2/2] feat(core): write and read reference edges with the content entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference selections ride in the content create/update body under a `references` key and are written in the same transaction as the entry, so a child that fails to resolve aborts the whole save rather than leaving an entry with taxonomies, bylines and SEO already committed. The editor GET opts into hydrating the first page of each reference field's children. Two paths that used to lose edges now carry them: duplicating an entry copies its outgoing references onto the copy, and purging a row clears the group's edges only once no sibling — trashed ones included, they are still restorable — is left to own them. Co-Authored-By: Claude Opus 5 --- .changeset/reference-edges-with-entry.md | 5 + packages/core/src/api/handlers/content.ts | 178 +++++- packages/core/src/api/handlers/relations.ts | 2 +- packages/core/src/api/schemas/content.ts | 13 + .../routes/api/content/[collection]/[id].ts | 4 +- packages/core/src/astro/types.ts | 3 + .../core/src/database/repositories/content.ts | 18 + .../src/database/repositories/relation.ts | 37 +- .../core/src/database/repositories/types.ts | 25 + packages/core/src/emdash-runtime.ts | 9 +- .../integration/api/references-edges.test.ts | 118 ++++ .../content/content-references-write.test.ts | 548 ++++++++++++++++++ packages/core/tests/unit/api/schemas.test.ts | 26 + 13 files changed, 969 insertions(+), 17 deletions(-) create mode 100644 .changeset/reference-edges-with-entry.md create mode 100644 packages/core/tests/integration/content/content-references-write.test.ts diff --git a/.changeset/reference-edges-with-entry.md b/.changeset/reference-edges-with-entry.md new file mode 100644 index 0000000000..b98858500d --- /dev/null +++ b/.changeset/reference-edges-with-entry.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds a `references` key to the content create and update bodies, so an entry's reference selections save in the same request — and the same transaction — as the entry itself. A child that fails to resolve aborts the whole save rather than leaving a half-written entry. The editor GET returns the first page of each reference field's children, duplicating an entry carries its references onto the copy, and purging the last row of a translation group clears the edges it owned. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 89e29c2670..d564c8e26d 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -12,6 +12,7 @@ import type { ContentBylineInput } from "../../database/repositories/byline.js"; import { CommentRepository } from "../../database/repositories/comment.js"; import { ContentRepository, isSystemOrderField } 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"; @@ -43,6 +44,7 @@ import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingColumnError, isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; +import { getReferenceTitleField, resolveEntries, setReferenceChildren } from "./relations.js"; import { validateMediaFields } from "./validate-media-fields.js"; /** @@ -92,22 +94,23 @@ async function collectionHasSeo(db: Kysely, collection: string): Promi return row?.has_seo === 1; } +/** A storage-less field's row: no column, so no value of its own in `data`. */ +type StoragelessField = { slug: string; type: string; validation: string | null }; + /** - * Field slugs on `collection` that persist no column, so no value of theirs - * belongs in an entry's `data` (see `STORAGELESS_FIELD_TYPES`). Memoized for the - * request: a single read hits this once per collection however many entries it - * covers. + * The storage-less fields on `collection` (see `STORAGELESS_FIELD_TYPES`). + * Memoized for the request: a single read hits this once per collection however + * many entries and reference fields it covers. */ -function storagelessFieldSlugs(db: Kysely, collection: string): Promise> { +function storagelessFields(db: Kysely, collection: string): Promise { return requestCached(`storageless-fields:${collection}`, async () => { - const rows = await db + return db .selectFrom("_emdash_fields") .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") - .select("_emdash_fields.slug") + .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) .where("_emdash_collections.slug", "=", collection) .where("_emdash_fields.type", "in", [...STORAGELESS_FIELD_TYPES]) .execute(); - return new Set(rows.map((row) => row.slug)); }); } @@ -124,9 +127,78 @@ async function stripStoragelessFromItem( collection: string, item: ContentItem, ): Promise { - const slugs = await storagelessFieldSlugs(db, collection); - if (slugs.size === 0) return; - for (const slug of slugs) delete item.data[slug]; + const fields = await storagelessFields(db, collection); + for (const field of fields) delete item.data[field.slug]; +} + +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, keyed by the field's relation group. + * + * 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. + * + * A reference 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 fields = (await storagelessFields(db, collection)).filter((f) => f.type === "reference"); + 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, + await getReferenceTitleField(db, childCollection), + ); + references[relationGroup] = { + children, + ...(edges.nextCursor ? { nextCursor: edges.nextCursor } : {}), + }; + } + + item.references = references; } async function collectionSupportsRevisions( @@ -724,6 +796,7 @@ export async function handleContentGet( collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ): Promise> { try { const repo = new ContentRepository(db); @@ -749,6 +822,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, @@ -837,6 +916,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; }, @@ -939,6 +1020,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; }); @@ -947,6 +1049,14 @@ export async function handleContentCreate( data: { item, _rev: encodeRev(item) }, }; } catch (error) { + // 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, @@ -1019,6 +1129,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> { @@ -1139,6 +1251,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; }); @@ -1220,8 +1352,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( @@ -1373,6 +1516,7 @@ export async function handleContentPermanentDelete( // Wrap content delete + SEO/comment cleanup in a transaction const deleted = await withTransaction(db, async (trx) => { const trxRepo = new ContentRepository(trx); + const item = await trxRepo.findByIdIncludingTrashed(collection, resolvedId); const wasDeleted = await trxRepo.permanentDelete(collection, resolvedId); if (wasDeleted) { @@ -1385,6 +1529,18 @@ export async function handleContentPermanentDelete( // Clean up revisions for permanently deleted content const revisionRepo = new RevisionRepository(trx); await revisionRepo.deleteByEntry(collection, resolvedId); + // Reference edges are keyed by translation_group, so they belong to the + // group rather than to this row — drop them only once no sibling + // (trashed ones included, they can still be restored) is left to own them. + if (item?.translationGroup) { + const groupSurvives = await trxRepo.hasTranslationsIncludingTrashed( + collection, + item.translationGroup, + ); + if (!groupSurvives) { + await new RelationRepository(trx).clearReferencesForGroup(item.translationGroup); + } + } } return wasDeleted; diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 6578468957..7ee4431a1a 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -314,7 +314,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/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index f2142ad99f..9276957440 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { SQL_BATCH_SIZE } from "../../utils/chunks.js"; import { bylineSummarySchema, bylineCreditSchema, contentBylineInputSchema } from "./bylines.js"; import { cursorPaginationQuery, httpUrl, localeCode } from "./common.js"; +import { referenceChildrenResponseSchema } from "./relations.js"; // --------------------------------------------------------------------------- // Content: Input schemas @@ -184,6 +185,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, }) @@ -206,6 +211,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" }); @@ -290,6 +299,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" }); 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 2c1ac3c1aa..0c56c6082d 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id].ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id].ts @@ -27,7 +27,9 @@ export const GET: APIRoute = async ({ params, url, locals }) => { const id = params.id!; const locale = url.searchParams.get("locale") || undefined; - 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 69f1e1b9ac..651c3a990e 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -265,6 +265,7 @@ export interface EmDashHandlers { collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ) => Promise< HandlerResponse<{ item: { @@ -287,6 +288,7 @@ export interface EmDashHandlers { locale?: string; translationOf?: string; taxonomies?: Record; + references?: Record; createdAt?: string | null; publishedAt?: string | null; }, @@ -310,6 +312,7 @@ export interface EmDashHandlers { noIndex?: boolean; }; taxonomies?: Record; + references?: Record; publishedAt?: string | null; _rev?: string; }, diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index c0676b6b71..75b6066aa0 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1462,6 +1462,24 @@ export class ContentRepository { return result.rows.map((row) => this.mapRow(type, row)); } + /** + * Whether any row still shares `translationGroup`, trashed rows included. + * Group-keyed satellite data (reference edges) is owned by the group, not by a + * single locale row, so a purge may only cascade to it once nothing is left to + * own it — and a trashed sibling is still restorable. + */ + async hasTranslationsIncludingTrashed(type: string, translationGroup: string): Promise { + const tableName = getTableName(type); + + const result = await sql>` + SELECT id FROM ${sql.ref(tableName)} + WHERE translation_group = ${translationGroup} + LIMIT 1 + `.execute(this.db); + + return result.rows.length > 0; + } + /** * Batch variant of {@link findTranslations}: every (non-deleted) locale * variant for any of `translationGroups`, in one `WHERE translation_group IN diff --git a/packages/core/src/database/repositories/relation.ts b/packages/core/src/database/repositories/relation.ts index fbb71d1bb8..6958736828 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 @@ -510,8 +543,8 @@ export class RelationRepository { * Remove every edge where `group` is the parent OR the child — i.e. ensure no * orphaned reference edges survive when a content entry is deleted. The * application-layer cascade that group-linking precludes at the SQL level. - * Wiring this into the content-delete path is a later (handler) slice. - * Returns the number of edges removed. + * Callers must be sure the whole group is gone: edges outlive any single + * locale row. Returns the number of edges removed. */ async clearReferencesForGroup(group: string): Promise { const result = await this.db diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 4f3fe1180c..129a2bf59e 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -306,6 +306,31 @@ 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; + title: string | null; + 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 846859a0ca..124d31ffbf 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2829,8 +2829,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/api/references-edges.test.ts b/packages/core/tests/integration/api/references-edges.test.ts index 41b6bc0644..0311150915 100644 --- a/packages/core/tests/integration/api/references-edges.test.ts +++ b/packages/core/tests/integration/api/references-edges.test.ts @@ -13,6 +13,7 @@ import { } from "../../../src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.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, setupForDialectWithCollections, @@ -79,6 +80,44 @@ describeEachDialect("reference children handlers", (dialect) => { expect(get.data.children.map((c) => c.slug)).toEqual(["a", "b"]); }); + it("resolved children take their title from the collection's titleField", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("page", { slug: "headline", label: "Headline", type: "string" }); + await registry.updateCollection("page", { titleField: "headline" }); + + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ + type: "page", + slug: "a", + data: { title: "A", headline: "Headline A" }, + }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + if (!set.success) return; + expect(set.data.children[0]?.title).toBe("Headline A"); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + if (!get.success) return; + expect(get.data.children[0]?.title).toBe("Headline A"); + }); + + it("falls back to title when the titleField is empty on the entry", async () => { + const registry = new SchemaRegistry(ctx.db); + await registry.createField("page", { slug: "headline", label: "Headline", type: "string" }); + await registry.updateCollection("page", { titleField: "headline" }); + + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + if (!set.success) return; + expect(set.data.children[0]?.title).toBe("A"); + }); + it("resolved children carry their actual locale", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -90,6 +129,33 @@ describeEachDialect("reference children handlers", (dialect) => { expect(set.data.children[0]?.locale).toBe("en"); }); + it("a resolved child carries the translation group of the variant it resolved to", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + locale: "fr", + }); + const en = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + const fr = await content.create({ + type: "page", + slug: "a-fr", + data: { title: "A (fr)" }, + locale: "fr", + translationOf: en.id, + }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [en.id]); + if (!set.success) return; + // The edge is keyed by group, so a `fr` parent resolves the `fr` variant — + // a different row id than the one that was linked. The group is what stays + // stable across those variants, so it rides along on the ref. + expect(set.data.children[0]?.id).toBe(fr.id); + expect(set.data.children[0]?.translationGroup).toBe(en.id); + }); + it("children GET paginates with a cursor", async () => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); @@ -185,6 +251,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 +318,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/integration/content/content-references-write.test.ts b/packages/core/tests/integration/content/content-references-write.test.ts new file mode 100644 index 0000000000..67b8c5c96b --- /dev/null +++ b/packages/core/tests/integration/content/content-references-write.test.ts @@ -0,0 +1,548 @@ +import { expect, it } from "vitest"; + +import { + handleContentCreate, + handleContentDelete, + handleContentDuplicate, + handleContentGet, + handleContentPermanentDelete, +} 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"; +import type { DialectTestContext } from "../../utils/test-db.js"; + +async function setupPostsWithRelation(db: DialectTestContext["db"]) { + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + + const relationRepo = new RelationRepository(db); + const relation = await relationRepo.create({ + name: "related_posts", + parentCollection: "posts", + childCollection: "posts", + parentLabel: "Related posts", + childLabel: "Related to", + }); + return { relationRepo, relation }; +} + +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); + } + }); +}); + +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("writes edges from a non-default-locale entry that its sibling locale reads back", async () => { + ctx = await setupForDialect(dialect); + try { + const { relation } = await setupPostsWithRelation(ctx.db); + await new SchemaRegistry(ctx.db).createField("posts", { + slug: "related", + label: "Related", + type: "reference", + validation: { + relation: relation.translationGroup, + targetCollection: "posts", + multiple: true, + }, + }); + + // The child exists only in `en`; the parent is edited in `fr`. Edges are + // keyed by translation_group, so neither side being in a different locale + // makes the reference any less real. + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child (en)" } }); + const parentEn = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + expect(child.success && parentEn.success).toBe(true); + if (!child.success || !parentEn.success) return; + + const parentFr = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent (fr)" }, + locale: "fr", + translationOf: parentEn.data.item.id, + references: { [relation.translationGroup]: [child.data.item.id] }, + }); + expect(parentFr.success).toBe(true); + if (!parentFr.success) return; + + // Written from the `fr` row, visible from the `en` row: same group, same edges. + const fromEn = await handleContentGet(ctx.db, "posts", parentEn.data.item.id, undefined, { + includeDrafts: true, + }); + expect(fromEn.success).toBe(true); + if (!fromEn.success) return; + expect( + fromEn.data.item.references?.[relation.translationGroup]?.children.map((c) => c.id), + ).toEqual([child.data.item.id]); + + // Read from `fr`, the child has no `fr` variant, so it resolves to the + // `en` row and says so rather than pretending to be French. + const fromFr = await handleContentGet(ctx.db, "posts", parentFr.data.item.id, "fr", { + includeDrafts: true, + }); + expect(fromFr.success).toBe(true); + if (!fromFr.success) return; + const frChildren = fromFr.data.item.references?.[relation.translationGroup]?.children; + expect(frChildren?.map((c) => c.id)).toEqual([child.data.item.id]); + expect(frChildren?.[0]?.locale).toBe("en"); + } 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); + } + }); +}); + +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); + } + }); +}); + +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); + } + }); +}); + +describeEachDialect("handleContentPermanentDelete clears reference edges", (dialect) => { + let ctx: DialectTestContext; + + it("removes edges on both sides when the last row of a translation group is purged", async () => { + ctx = await setupForDialect(dialect); + try { + const { relationRepo, relation } = await setupPostsWithRelation(ctx.db); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const middle = await handleContentCreate(ctx.db, "posts", { data: { title: "Middle" } }); + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(parent.success && middle.success && child.success).toBe(true); + if (!parent.success || !middle.success || !child.success) return; + + // The purged entry sits in the middle of a chain: parent → middle → child, + // so both its outgoing and incoming edges must go. + expect( + ( + await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.translationGroup, + [middle.data.item.id], + ) + ).success, + ).toBe(true); + expect( + ( + await setReferenceChildren( + ctx.db, + "posts", + middle.data.item.id, + relation.translationGroup, + [child.data.item.id], + ) + ).success, + ).toBe(true); + + expect((await handleContentDelete(ctx.db, "posts", middle.data.item.id)).success).toBe(true); + const purged = await handleContentPermanentDelete(ctx.db, "posts", middle.data.item.id); + expect(purged.success).toBe(true); + + const outgoing = await relationRepo.getChildrenPage( + relation.translationGroup, + middle.data.item.id, + ); + expect(outgoing.items).toEqual([]); + const incoming = await relationRepo.getParentsPage( + relation.translationGroup, + middle.data.item.id, + ); + expect(incoming.items).toEqual([]); + } finally { + await teardownForDialect(ctx); + } + }); + + it("keeps the group's edges when a translation sibling survives the purge", async () => { + ctx = await setupForDialect(dialect); + try { + const { relationRepo, relation } = await setupPostsWithRelation(ctx.db); + + const parent = await handleContentCreate(ctx.db, "posts", { data: { title: "Parent" } }); + const child = await handleContentCreate(ctx.db, "posts", { data: { title: "Child" } }); + expect(parent.success && child.success).toBe(true); + if (!parent.success || !child.success) return; + + const translation = await handleContentCreate(ctx.db, "posts", { + data: { title: "Parent (fr)" }, + locale: "fr", + translationOf: parent.data.item.id, + }); + expect(translation.success).toBe(true); + if (!translation.success) return; + + expect( + ( + await setReferenceChildren( + ctx.db, + "posts", + parent.data.item.id, + relation.translationGroup, + [child.data.item.id], + ) + ).success, + ).toBe(true); + + // Edges are keyed by translation_group, so purging one locale row must not + // strip references still owned by its surviving sibling. + expect((await handleContentDelete(ctx.db, "posts", translation.data.item.id)).success).toBe( + true, + ); + const purged = await handleContentPermanentDelete(ctx.db, "posts", translation.data.item.id); + expect(purged.success).toBe(true); + + const page = await relationRepo.getChildrenPage( + relation.translationGroup, + parent.data.item.id, + ); + expect(page.items.map((i) => i.childGroup)).toEqual([child.data.item.id]); + } finally { + await teardownForDialect(ctx); + } + }); +}); diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts index 7a84618c61..e399646e6b 100644 --- a/packages/core/tests/unit/api/schemas.test.ts +++ b/packages/core/tests/unit/api/schemas.test.ts @@ -63,6 +63,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", () => { @@ -122,6 +135,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("localeCode validator", () => {