Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reference-edges-with-entry.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/reference-required-multiple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Enforces a reference field's `required` and `multiple` settings on the server. A field configured for a single reference now rejects a payload with more than one, and a required field rejects an empty selection or a create that omits it. Both hold wherever the edges are written — the content body, the reference endpoints, and seeds — so the constraint can't be sidestepped by picking a different entry point.
5 changes: 5 additions & 0 deletions .changeset/storageless-reference-fields.md
Original file line number Diff line number Diff line change
@@ -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.
183 changes: 183 additions & 0 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,7 +42,9 @@ 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";
import { storagelessFields, validateRequiredReferencesPresent } from "./validate-references.js";

/**
* Narrow a caught error to one carrying a structured `apiError` discriminant.
Expand Down Expand Up @@ -90,6 +93,93 @@ async function collectionHasSeo(db: Kysely<Database>, collection: string): Promi
return row?.has_seo === 1;
}

/**
* 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<Database>,
collection: string,
item: ContentItem,
): Promise<void> {
const fields = await storagelessFields(db, collection);
for (const field of fields) delete item.data[field.slug];
}

function isRecord(value: unknown): value is Record<string, unknown> {
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<Database>,
collection: string,
item: ContentItem,
includeDrafts: boolean,
): Promise<void> {
if (!item.translationGroup) return;

const fields = (await storagelessFields(db, collection)).filter((f) => f.type === "reference");
const references: NonNullable<ContentItem["references"]> = {};
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<string, unknown> = {};
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<Database>,
collection: string,
Expand Down Expand Up @@ -685,6 +775,7 @@ export async function handleContentGet(
collection: string,
id: string,
locale?: string,
referenceOptions?: { includeDrafts: boolean },
): Promise<ApiResult<ContentResponse>> {
try {
const repo = new ContentRepository(db);
Expand All @@ -704,10 +795,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,
Expand Down Expand Up @@ -753,6 +852,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);
Expand Down Expand Up @@ -794,6 +895,8 @@ export async function handleContentCreate(
translationOf?: string;
seo?: ContentSeoInput;
taxonomies?: Record<string, string[]>;
/** Reference fields: relation translation_group → ordered child entry ids. */
references?: Record<string, string[]>;
createdAt?: string | null;
publishedAt?: string | null;
},
Expand All @@ -815,6 +918,11 @@ export async function handleContentCreate(
const mimeCheck = await validateMediaFields(db, collection, body.data);
if (!mimeCheck.success) return mimeCheck;

// Selections that are present get checked as they're written; this is the
// required field the payload leaves out altogether.
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] handleContentCreate rejects creates that omit a required reference field before opening the transaction. That is correct for a fresh entry, but it also applies when body.translationOf is set. Translations share the source's translation_group, so reference edges are already satisfied by the source row; requiring the caller to re-send them breaks translation creation for collections with required references.

Skip the omitted-ref check when creating a translation (explicit body.references still flows through setReferenceChildren and is validated there):

Suggested change
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);
// Selections that are present get checked as they're written; this is the
// required field the payload leaves out altogether. Translations share
// their source's translation_group (and therefore its reference edges),
// so they inherit any required references already satisfied there.
if (!body.translationOf) {
const requiredRefs = await validateRequiredReferencesPresent(db, collection, body.references);
if (!requiredRefs.success) return requiredRefs;
}

if (!requiredRefs.success) return requiredRefs;

// Wrap content + SEO writes in a transaction for atomicity
const item = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
Expand Down Expand Up @@ -896,6 +1004,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;
});

Expand All @@ -904,6 +1033,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,
Expand Down Expand Up @@ -976,6 +1113,8 @@ export async function handleContentUpdate(
_rev?: string;
seo?: ContentSeoInput;
taxonomies?: Record<string, string[]>;
/** Reference fields: relation translation_group → ordered child entry ids. */
references?: Record<string, string[]>;
publishedAt?: string | null;
},
): Promise<ApiResult<ContentResponse>> {
Expand Down Expand Up @@ -1096,6 +1235,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;
});

Expand Down Expand Up @@ -1177,8 +1336,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(
Expand Down Expand Up @@ -1330,6 +1500,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) {
Expand All @@ -1342,6 +1513,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;
Expand Down
Loading
Loading