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/.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..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"; @@ -36,11 +37,14 @@ 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"; 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"; /** @@ -90,6 +94,113 @@ 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 }; + +/** + * 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 storagelessFields(db: Kysely, collection: string): Promise { + return requestCached(`storageless-fields:${collection}`, async () => { + return db + .selectFrom("_emdash_fields") + .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id") + .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"]) + .where("_emdash_collections.slug", "=", collection) + .where("_emdash_fields.type", "in", [...STORAGELESS_FIELD_TYPES]) + .execute(); + }); +} + +/** + * 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 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( db: Kysely, collection: string, @@ -685,6 +796,7 @@ export async function handleContentGet( collection: string, id: string, locale?: string, + referenceOptions?: { includeDrafts: boolean }, ): Promise> { try { const repo = new ContentRepository(db); @@ -704,10 +816,18 @@ 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); 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, @@ -753,6 +873,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); @@ -794,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; }, @@ -896,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; }); @@ -904,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, @@ -976,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> { @@ -1096,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; }); @@ -1177,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( @@ -1330,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) { @@ -1342,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 3581ae1696..7ee4431a1a 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, @@ -268,13 +314,14 @@ 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[], 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/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/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/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/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..124d31ffbf 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 }; @@ -2824,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/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/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/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/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", () => { 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 () => {