/** * Content CRUD handlers */ import type { Kysely } from "kysely"; import { sql } from "kysely"; import type { ContentFieldFilters } from "../../content-list-query.js"; import { isSqlite } from "../../database/dialect-helpers.js"; import { BylineRepository } from "../../database/repositories/byline.js"; 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 { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; import { ContentCollectionNotFoundError, ContentMutationConflictError, EmDashValidationError, ScheduledNotDueError, InvalidCursorError, type BylineSummary, type ContentBylineCredit, type ContentBylineFilter, type ContentDateField, type ContentItem, type ContentSeo, type ContentSeoInput, type FindManyOptions, } from "../../database/repositories/types.js"; import { UserRepository } from "../../database/repositories/user.js"; import { withTransaction } from "../../database/transaction.js"; 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 { 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 { validateMediaFields } from "./validate-media-fields.js"; /** * Narrow a caught error to one carrying a structured `apiError` discriminant. * Used by transaction callbacks that want to surface a specific error code * through the standard Error throwing path. */ function hasApiError(error: unknown): error is Error & { apiError: { code: string } } { if (!(error instanceof Error) || !("apiError" in error)) return false; const { apiError } = error; return ( typeof apiError === "object" && apiError !== null && "code" in apiError && typeof apiError.code === "string" ); } /** * Extract a slug source (title or name) from content data. * Returns null if no suitable string field is found. */ function getSlugSource(data: Record): string | null { if (typeof data.title === "string" && data.title.length > 0) return data.title; if (typeof data.name === "string" && data.name.length > 0) return data.name; return null; } /** Default SEO values for content without an explicit SEO row */ const SEO_DEFAULTS: ContentSeo = { title: null, description: null, image: null, canonical: null, noIndex: false, }; /** * Check if a collection has SEO enabled. */ async function collectionHasSeo(db: Kysely, collection: string): Promise { const row = await db .selectFrom("_emdash_collections") .select("has_seo") .where("slug", "=", collection) .executeTakeFirst(); return row?.has_seo === 1; } async function getCollectionPublishConfig( db: Kysely, collection: string, ): Promise<{ supportsRevisions: boolean; routable: boolean }> { const row = await db .selectFrom("_emdash_collections") .select(["supports", "routable"]) .where("slug", "=", collection) .executeTakeFirst(); const supports: unknown = row?.supports ? JSON.parse(row.supports) : []; return { supportsRevisions: Array.isArray(supports) && supports.includes("revisions"), routable: row?.routable !== 0, }; } function requireRoutablePublishSlug(routable: boolean, slug: string | null | undefined): void { if (routable && !slug?.trim()) { throw new EmDashValidationError("Cannot publish routable content without a slug"); } } /** * Hydrate SEO data on a single content item if the collection has SEO enabled. */ async function hydrateSeo( db: Kysely, collection: string, item: ContentItem, hasSeo: boolean, ): Promise { if (!hasSeo) return; const seoRepo = new SeoRepository(db); item.seo = await seoRepo.get(collection, item.id); } /** * Hydrate SEO data on multiple content items using a single batch query. */ async function hydrateSeoMany( db: Kysely, collection: string, items: ContentItem[], hasSeo: boolean, ): Promise { if (!hasSeo || items.length === 0) return; const seoRepo = new SeoRepository(db); const seoMap = await seoRepo.getMany( collection, items.map((i) => i.id), ); for (const item of items) { item.seo = seoMap.get(item.id) ?? { ...SEO_DEFAULTS }; } } async function hydrateBylines( db: Kysely, collection: string, item: ContentItem, ): Promise { const bylineRepo = new BylineRepository(db); // Strict per-locale (migration 040): a credit at locale X renders iff a // byline row exists at locale X in the credited translation_group. The // junction itself spans translations; rendering does not fall back. const localeOpt = item.locale ? { locale: item.locale } : undefined; const bylines = await bylineRepo.getContentBylines(collection, item.id, localeOpt); if (bylines.length > 0) { item.bylines = bylines.map((c) => ({ ...c, source: "explicit" as const })); item.byline = bylines[0]?.byline ?? null; return; } // `primaryBylineId` is set iff junction rows exist; non-null // suppresses author fallback even when the credit doesn't resolve // at this locale. if (item.primaryBylineId) { item.bylines = []; item.byline = null; return; } if (item.authorId) { // Same strict-locale rule as explicit credits: a user-linked byline // renders on the entry only when a sibling exists at the entry's // locale. Without this we'd silently surface the default-locale // row, which contradicts the per-locale model. const fallback = await bylineRepo.findByUserId(item.authorId, localeOpt); if (fallback) { item.bylines = [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }]; item.byline = fallback; return; } } item.bylines = []; item.byline = null; } /** * Batch-hydrate bylines for multiple items using two bulk queries instead of N+1. * * Items may live at different locales (e.g. a list endpoint returning the * translations of an entry). Group by `item.locale` and call the strict * per-locale repo method once per group so each item resolves against its * own locale's byline rows. */ async function hydrateBylinesMany( db: Kysely, collection: string, items: ContentItem[], ): Promise { if (items.length === 0) return; const bylineRepo = new BylineRepository(db); // 1. Bucket items by locale so we can call the strict-locale repo // once per bucket. Items with a null/undefined locale (pre-i18n // rows on a single-locale install) share an "unscoped" bucket. const localeBuckets = new Map(); for (const item of items) { const key = item.locale ?? null; const bucket = localeBuckets.get(key); if (bucket) bucket.push(item); else localeBuckets.set(key, [item]); } // 2. Per-locale: fetch explicit credits. Items whose credits don't // resolve at this locale go through a locale-agnostic "has any // junction" check before being considered for author inference — // explicit editorial intent at any locale beats inferred fallback. const bylinesByItem = new Map(); const itemsNeedingAuthorCheck: ContentItem[] = []; for (const [locale, bucket] of localeBuckets) { const localeOpt = locale ? { locale } : undefined; const ids = bucket.map((i) => i.id); const credits = await bylineRepo.getContentBylinesMany(collection, ids, localeOpt); for (const [id, list] of credits) bylinesByItem.set(id, list); for (const item of bucket) { if (credits.has(item.id) && credits.get(item.id)!.length > 0) continue; if (item.authorId) itemsNeedingAuthorCheck.push(item); } } // 3. Author fallback applies only when no explicit credit exists // (primaryBylineId null). const fallbackByItem = new Map(); if (itemsNeedingAuthorCheck.length > 0) { const authorBuckets = new Map(); for (const item of itemsNeedingAuthorCheck) { if (item.primaryBylineId) continue; const key = item.locale ?? null; const bucket = authorBuckets.get(key); if (bucket) bucket.push(item); else authorBuckets.set(key, [item]); } for (const [locale, bucket] of authorBuckets) { const localeOpt = locale ? { locale } : undefined; const authorIds = bucket.map((i) => i.authorId).filter((id): id is string => id !== null); const uniqueAuthorIds = [...new Set(authorIds)]; if (uniqueAuthorIds.length === 0) continue; const authorMap = await bylineRepo.findByUserIds(uniqueAuthorIds, localeOpt); for (const item of bucket) { if (!item.authorId) continue; const f = authorMap.get(item.authorId); if (f) fallbackByItem.set(item.id, f); } } } // 4. Assign to each item. for (const item of items) { const explicit = bylinesByItem.get(item.id); if (explicit && explicit.length > 0) { item.bylines = explicit.map((c) => ({ ...c, source: "explicit" as const })); item.byline = explicit[0]?.byline ?? null; continue; } const fallback = fallbackByItem.get(item.id); if (fallback) { item.bylines = [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }]; item.byline = fallback; continue; } item.bylines = []; item.byline = null; } } /** * Resolve an identifier (ID or slug) to a real content ID. * Returns the ID if found, null if not found. * When locale is provided, slug lookups are scoped to that locale. */ async function resolveId( repo: ContentRepository, collection: string, identifier: string, locale?: string, ): Promise { const item = await repo.findByIdOrSlug( collection, identifier, locale ? resolveConfiguredLocale(locale) : undefined, ); return item?.id ?? null; } /** * Resolve an identifier (ID or slug) to a real content ID, * including trashed (soft-deleted) items. */ async function resolveIdIncludingTrashed( repo: ContentRepository, collection: string, identifier: string, locale?: string, ): Promise { const item = await repo.findByIdOrSlugIncludingTrashed( collection, identifier, locale ? resolveConfiguredLocale(locale) : undefined, ); return item?.id ?? null; } /** * Trashed content item with deletion timestamp */ export interface TrashedContentItem { id: string; type: string; slug: string | null; status: string; data: Record; authorId: string | null; createdAt: string; updatedAt: string; publishedAt: string | null; deletedAt: string; } /** * Resolve the columns a content-list search should match against. Always * includes `slug` (a standard column), adds the configured `titleField` plus * the `title`/`name` display fields when the collection actually defines them, * mirroring the admin's item-title resolution (titleField -> title -> name -> * slug), and includes every field explicitly marked searchable. Returning only * schema-backed columns avoids "no such column" errors. */ async function resolveSearchColumns(db: Kysely, collection: string): Promise { const row = await db .selectFrom("_emdash_collections") .select(["id", "title_field"]) .where("slug", "=", collection) .executeTakeFirst(); if (!row) return ["slug"]; const fields = await db .selectFrom("_emdash_fields") .select(["slug", "searchable"]) .where("collection_id", "=", row.id) .orderBy("sort_order", "asc") .execute(); const columns = new Set(["slug"]); const fieldSlugs = new Set(fields.map((f) => f.slug)); // A configured titleField takes precedence, then the conventional // title/name fields. A null title_field falls through to those defaults. if (row.title_field && fieldSlugs.has(row.title_field)) columns.add(row.title_field); for (const candidate of ["title", "name"]) { if (fieldSlugs.has(candidate)) columns.add(candidate); } for (const field of fields) { if (field.searchable === 1) columns.add(field.slug); } return [...columns]; } /** * Decide whether the content-list `q` filter can be served from the * collection's FTS5 index instead of a full-scan substring LIKE (#1517). * * Requires SQLite (FTS5 is SQLite-only), search enabled on the collection, * every non-slug display column present in the searchable-field set (or the * index would miss matches the LIKE finds), and the index table actually * existing. */ async function canUseFtsForListFilter( db: Kysely, collection: string, searchColumns: string[], ): Promise { if (!isSqlite(db)) return false; const ftsManager = new FTSManager(db); const config = await ftsManager.getSearchConfig(collection); if (!config?.enabled) return false; const searchable = new Set(await ftsManager.getSearchableFields(collection)); const covered = searchColumns.every((col) => col === "slug" || searchable.has(col)); if (!covered) return false; return ftsManager.ftsTableExists(collection); } /** * Create a 301 auto-redirect from an entry's old URL to its new one after a * slug change, using the collection's URL pattern. Shared by * handleContentUpdate (direct slug edits) and handleContentPublish (slug edits * staged as `_slug` in a draft revision, which only land on publish). */ async function createSlugChangeRedirect( db: Kysely, collection: string, oldSlug: string, newSlug: string, contentId: string, ): Promise { // A URL pattern has no locale token, so every locale variant of an entry // generates the same URL, and slugs are unique per (slug, locale) — a // translation may still hold the old slug. Redirecting away from a URL // another row still answers on would take that page down: the redirect // middleware runs `order: "pre"`, so routing never gets a chance. // Any surviving row counts, published or not: a draft that publishes later // would otherwise be shadowed by the redirect. if (await slugStillTaken(db, collection, oldSlug, contentId)) return; const collectionRow = await db .selectFrom("_emdash_collections") .select("url_pattern") .where("slug", "=", collection) .executeTakeFirst(); const redirectRepo = new RedirectRepository(db); await redirectRepo.createAutoRedirect( collection, oldSlug, newSlug, contentId, collectionRow?.url_pattern ?? null, ); invalidateRedirectCache(); } /** Whether a row other than `contentId` still holds `slug` in this collection. */ async function slugStillTaken( db: Kysely, collection: string, slug: string, contentId: string, ): Promise { validateIdentifier(collection, "collection slug"); const result = await sql<{ id: string }>` SELECT id FROM ${sql.ref(`ec_${collection}`)} WHERE slug = ${slug} AND id != ${contentId} AND deleted_at IS NULL LIMIT 1 `.execute(db); return result.rows.length > 0; } /** Matches a date-only `YYYY-MM-DD` bound (no time component). */ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; /** * Normalize a date-range bound to an ISO datetime for lexicographic comparison * against stored ISO 8601 timestamps. A bare `YYYY-MM-DD` is widened to the * appropriate UTC day boundary so the range stays inclusive: a `start` bound * becomes the start of the day and an `end` bound the end of the day. * Otherwise a date-only upper bound would exclude every same-day row (since * `2024-06-01T12:00:00Z` sorts after `2024-06-01`). Full datetimes pass * through unchanged. */ function normalizeDateBound(value: string | undefined, edge: "start" | "end"): string | undefined { if (!value) return undefined; if (!DATE_ONLY_RE.test(value)) return value; return edge === "start" ? `${value}T00:00:00.000Z` : `${value}T23:59:59.999Z`; } /** * Build the repository's byline filter from the wire params. * * `locale` is the locale the list is scoped to, which is the locale an * inferred credit has to resolve at — the admin list is always scoped to the * locale picked in its switcher. */ function resolveBylineFilter( params: { bylines?: string[]; bylinesNone?: boolean; includeInferredBylines?: boolean }, locale: string | undefined, ): ContentBylineFilter | undefined { const includeInferred = params.includeInferredBylines === true; if (params.bylinesNone) return { mode: "none", includeInferred, locale }; const bylineIds = params.bylines ?? []; if (bylineIds.length === 0) return undefined; return { mode: "any", bylineIds, includeInferred, locale }; } /** * Create content list handler */ export async function handleContentList( db: Kysely, collection: string, params: { cursor?: string; limit?: number; status?: string; orderBy?: string; order?: "asc" | "desc"; locale?: string; q?: string; authorId?: string; dateField?: ContentDateField; dateFrom?: string; dateTo?: string; bylines?: string[]; bylinesNone?: boolean; includeInferredBylines?: boolean; fieldFilters?: ContentFieldFilters; }, ): Promise> { try { const repo = new ContentRepository(db); const where: FindManyOptions["where"] = {}; if (params.status) where.status = params.status; const locale = params.locale ? resolveConfiguredLocale(params.locale) : undefined; if (locale) where.locale = locale; if (params.authorId) where.authorId = params.authorId; if (params.fieldFilters && Object.keys(params.fieldFilters).length > 0) { where.fieldFilters = params.fieldFilters; } const bylineFilter = resolveBylineFilter(params, locale); if (bylineFilter) where.bylineFilter = bylineFilter; // A date range requires a target column; ignore stray from/to without // a field so a half-specified filter doesn't silently drop all rows. if (params.dateField && (params.dateFrom || params.dateTo)) { where.dateFilter = { field: params.dateField, from: normalizeDateBound(params.dateFrom, "start"), to: normalizeDateBound(params.dateTo, "end"), }; } const q = params.q?.trim(); if (q) { where.q = q; where.searchColumns = await resolveSearchColumns(db, collection); where.useFts = await canUseFtsForListFilter(db, collection, where.searchColumns); } // Sorting by a non-system field (a collection's titleField/dateField) // needs the collection's *actual* sort fields resolved server-side, // so the orderBy set stays closed. Only query when it's not a system field. let sortableExtras: string[] | undefined; if (params.orderBy && !isSystemOrderField(params.orderBy)) { const coll = await db .selectFrom("_emdash_collections") .select(["title_field", "date_field"]) .where("slug", "=", collection) .executeTakeFirst(); sortableExtras = [coll?.title_field, coll?.date_field].filter( (slug): slug is string => !!slug, ); } const result = await repo.findMany(collection, { cursor: params.cursor, limit: params.limit || 50, where: Object.keys(where).length > 0 ? where : undefined, orderBy: params.orderBy ? { field: params.orderBy, direction: params.order || "desc" } : undefined, sortableExtras, }); // Hydrate SEO data if the collection has SEO enabled const hasSeo = await collectionHasSeo(db, collection); await hydrateSeoMany(db, collection, result.items, hasSeo); await hydrateBylinesMany(db, collection, result.items); return { success: true, data: { items: result.items, nextCursor: result.nextCursor, total: result.total, }, }; } catch (error) { if (error instanceof InvalidCursorError) { return { success: false, error: { code: "INVALID_CURSOR", message: error.message }, }; } if (error instanceof ContentCollectionNotFoundError || isMissingTableError(error)) { return { success: false, error: { code: "COLLECTION_NOT_FOUND", message: `Collection '${collection}' not found`, }, }; } if (isMissingColumnError(error, "deleted_at")) { return { success: false, error: { code: "COLLECTION_SCHEMA_MISMATCH", message: `Collection '${collection}' backing table is missing the 'deleted_at' column`, }, }; } if (error instanceof EmDashValidationError) { // e.g. invalid orderBy field return { success: false, error: { code: "VALIDATION_ERROR", message: error.message }, }; } console.error("Content list error:", error); return { success: false, error: { code: "CONTENT_LIST_ERROR", message: "Failed to list content", }, }; } } /** A content author option for the admin author filter. */ export interface ContentAuthor { id: string; name: string | null; email: string; avatarUrl: string | null; } /** * List the distinct authors of a collection's live content. * * Backs the admin content-list author filter. Unlike `/admin/users` (ADMIN * only), this is gated on `content:read`, so any editor can filter by author. * Returns only users who have authored at least one non-trashed entry, sorted * by display name then email for a stable dropdown order. */ export async function handleContentAuthors( db: Kysely, collection: string, ): Promise> { try { const repo = new ContentRepository(db); const authorIds = await repo.findDistinctAuthorIds(collection); if (authorIds.length === 0) { return { success: true, data: { items: [] } }; } const userRepo = new UserRepository(db); const users = await userRepo.findByIds(authorIds); const items: ContentAuthor[] = users .map((u) => ({ id: u.id, name: u.name, email: u.email, avatarUrl: u.avatarUrl })) .toSorted((a, b) => (a.name ?? a.email).localeCompare(b.name ?? b.email)); return { success: true, data: { items } }; } catch (error) { if (isMissingTableError(error)) { return { success: false, error: { code: "COLLECTION_NOT_FOUND", message: `Collection '${collection}' not found`, }, }; } console.error("Content authors error:", error); return { success: false, error: { code: "CONTENT_AUTHORS_ERROR", message: "Failed to list content authors", }, }; } } /** * Get single content item */ export async function handleContentGet( db: Kysely, collection: string, id: string, locale?: string, ): Promise> { try { const repo = new ContentRepository(db); const item = await repo.findByIdOrSlug( collection, id, locale ? resolveConfiguredLocale(locale) : undefined, ); if (!item) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } // 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); return { success: true, data: { item, _rev: encodeRev(item) }, }; } catch (error) { console.error("Content get error:", error); return { success: false, error: { code: "CONTENT_GET_ERROR", message: "Failed to get content", }, }; } } /** * Get a content item by id, including trashed items. * Used by restore endpoint for ownership checks on soft-deleted items. */ export async function handleContentGetIncludingTrashed( db: Kysely, collection: string, id: string, locale?: string, ): Promise> { try { const repo = new ContentRepository(db); const item = await repo.findByIdOrSlugIncludingTrashed( collection, id, locale ? resolveConfiguredLocale(locale) : undefined, ); if (!item) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } // 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); return { success: true, data: { item, _rev: encodeRev(item) }, }; } catch (error) { console.error("Content get error:", error); return { success: false, error: { code: "CONTENT_GET_ERROR", message: "Failed to get content", }, }; } } /** * Create content item. * * Content + SEO writes are wrapped in a transaction so either both succeed * or neither does. If `body.seo` is provided for a non-SEO collection, the * API returns a validation error rather than silently dropping it. */ export async function handleContentCreate( db: Kysely, collection: string, body: { data: Record; slug?: string | null; status?: string; authorId?: string; bylines?: ContentBylineInput[]; locale?: string; translationOf?: string; seo?: ContentSeoInput; taxonomies?: Record; createdAt?: string | null; publishedAt?: string | null; }, ): Promise> { try { const hasSeo = await collectionHasSeo(db, collection); // Reject SEO input for non-SEO collections if (body.seo && !hasSeo) { return { success: false, error: { code: "VALIDATION_ERROR", message: `Collection "${collection}" does not have SEO enabled. Remove the seo field or enable SEO on this collection.`, }, }; } const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; // Wrap content + SEO writes in a transaction for atomicity const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const bylineRepo = new BylineRepository(trx); // Default to the configured site locale rather than the repo's // hard-coded "en" — otherwise non-English default-locale sites // silently create entries in a locale the editor never chose. const effectiveLocale = body.locale ? resolveConfiguredLocale(body.locale) : getI18nConfig()?.defaultLocale; let slug: string | null | undefined = body.slug; if (!slug) { const slugSource = getSlugSource(body.data); if (slugSource) { slug = await repo.generateUniqueSlug(collection, slugSource, effectiveLocale); } } if (body.status === "published") { const publishConfig = await getCollectionPublishConfig(trx, collection); requireRoutablePublishSlug(publishConfig.routable, slug); } const created = await repo.create({ type: collection, slug, data: body.data, status: body.status || "draft", authorId: body.authorId, locale: effectiveLocale, translationOf: body.translationOf, createdAt: body.createdAt, publishedAt: body.publishedAt, }); if (body.bylines !== undefined) { const credits = await bylineRepo.setContentBylines(collection, created.id, body.bylines); // `setContentBylines` translates wire row ids to their // `translation_group` before writing. The response-shape // `primaryBylineId` must match what's now in the DB, so read // it from the returned credit (whose `byline` came from a // hydration round-trip). created.primaryBylineId = credits[0]?.byline.translationGroup ?? null; } // Taxonomy assignments already belong to the content translation // group. Byline credits remain per content row and need copying. // Explicit `body.bylines` wins — `copyContentBylines` no-ops // when the target already has credits, but the cleaner guard // is to skip the call entirely. if (body.translationOf) { if (body.bylines === undefined) { await bylineRepo.copyContentBylines(collection, body.translationOf, created.id); // `copyContentBylines` writes the source's primary // pointer onto the new row; reflect it in-memory so the // response includes it before hydrateBylines runs. const source = await repo.findById(collection, body.translationOf); if (source) created.primaryBylineId = source.primaryBylineId; } } await hydrateBylines(trx, collection, created); // Side-write SEO data if provided if (body.seo && hasSeo) { const seoRepo = new SeoRepository(trx); created.seo = await seoRepo.upsert(collection, created.id, body.seo); } else if (hasSeo) { // Assign defaults in-memory — no DB round-trip needed created.seo = { ...SEO_DEFAULTS }; } // Attach taxonomy terms in the same transaction. The MCP tool // (and the REST create body) previously accepted a `taxonomies` // field on `content_create` without doing anything with it, so // agents publishing a categorized/tagged entry had to make N // follow-up REST calls per taxonomy. This resolves each slug in // the entry's locale and pipes it through the same // `setTermsForEntry` path the `.../terms/{taxonomy}` REST route // uses, so the two entry points can't drift. if (body.taxonomies) { await assignTaxonomies(trx, collection, created.id, effectiveLocale, body.taxonomies); } return created; }); return { success: true, data: { item, _rev: encodeRev(item) }, }; } catch (error) { if (isMissingTableError(error)) { return { success: false, error: { code: "COLLECTION_NOT_FOUND", message: `Collection '${collection}' not found`, }, }; } if (error instanceof EmDashValidationError) { return { success: false, error: { code: "VALIDATION_ERROR", message: error.message }, }; } // SQLite UNIQUE constraint OR Postgres unique_violation — slug // collisions and any other unique violations land here. Match // specifically on "unique constraint failed" / "duplicate key" so we // don't false-positive on NOT NULL or CHECK violations whose // messages also contain "constraint failed". const message = error instanceof Error ? error.message.toLowerCase() : ""; if (message.includes("unique constraint failed") || message.includes("duplicate key")) { // Detect slug-specific collisions by message fingerprint if (message.includes("slug")) { return { success: false, error: { code: "SLUG_CONFLICT", message: `Slug '${body.slug ?? "(auto-generated)"}' already exists in collection '${collection}'`, }, }; } return { success: false, error: { code: "CONFLICT", message: "Unique constraint violation", }, }; } console.error("Content create error:", error); return { success: false, error: { code: "CONTENT_CREATE_ERROR", message: "Failed to create content", }, }; } } /** * Update content item. * If `_rev` is provided, validates it against the current version before writing. * No `_rev` = blind write (backwards-compatible for admin UI). * * Content + SEO writes are wrapped in a transaction for atomicity. */ export async function handleContentUpdate( db: Kysely, collection: string, id: string, body: { data?: Record; slug?: string | null; status?: string; authorId?: string | null; bylines?: ContentBylineInput[]; locale?: string; _rev?: string; seo?: ContentSeoInput; taxonomies?: Record; publishedAt?: string | null; }, ): Promise> { try { const hasSeo = await collectionHasSeo(db, collection); // Reject SEO input for non-SEO collections if (body.seo && !hasSeo) { return { success: false, error: { code: "VALIDATION_ERROR", message: `Collection "${collection}" does not have SEO enabled. Remove the seo field or enable SEO on this collection.`, }, }; } if (body.data) { const mimeCheck = await validateMediaFields(db, collection, body.data); if (!mimeCheck.success) return mimeCheck; } const repo = new ContentRepository(db); // Resolve slug → ID if needed const resolvedId = (await resolveId(repo, collection, id, body.locale)) ?? id; // Wrap content + SEO writes in a transaction for atomicity. // The _rev check is inside the transaction so the read-then-write // is atomic -- no concurrent write can slip between the check and update. const item = await withTransaction(db, async (trx) => { const trxRepo = new ContentRepository(trx); const bylineRepo = new BylineRepository(trx); // Read existing item once for both _rev check and old slug capture const existing = body._rev || body.slug !== undefined || body.status === "published" ? await trxRepo.findById(collection, resolvedId) : null; // Validate _rev if provided (optimistic concurrency) if (body._rev) { if (!existing) { throw Object.assign(new Error(`Content item not found: ${id}`), { apiError: { code: "NOT_FOUND" as const }, }); } const revCheck = validateRev(body._rev, existing); if (!revCheck.valid) { throw Object.assign(new Error(revCheck.message), { apiError: { code: "CONFLICT" as const }, }); } } // Capture old slug before update for auto-redirect let oldSlug: string | undefined; if (body.slug && existing?.slug && existing.slug !== body.slug) { oldSlug = existing.slug; } const resultingStatus = body.status ?? existing?.status; if (resultingStatus === "published") { if (!existing) { throw Object.assign(new Error(`Content item not found: ${id}`), { apiError: { code: "NOT_FOUND" as const }, }); } const publishConfig = await getCollectionPublishConfig(trx, collection); const intendedSlug = body.slug !== undefined ? body.slug : existing.slug; requireRoutablePublishSlug(publishConfig.routable, intendedSlug); } const updated = await trxRepo.update(collection, resolvedId, { data: body.data, slug: body.slug, status: body.status, authorId: body.authorId, publishedAt: body.publishedAt, }); if (body.bylines !== undefined) { const credits = await bylineRepo.setContentBylines(collection, resolvedId, body.bylines); // `setContentBylines` translates wire row ids to their // `translation_group` before writing. Read the in-memory // pointer from the persisted credit so the response shape // matches the DB. See the matching block in handleContentCreate. updated.primaryBylineId = credits[0]?.byline.translationGroup ?? null; } // Create auto-redirect when slug changes if (oldSlug && body.slug) { await createSlugChangeRedirect(trx, collection, oldSlug, body.slug, resolvedId); } // Sync non-translatable fields to sibling locales in the same // translation group. Only runs when i18n is enabled, data was updated, // and the item belongs to a translation group with siblings. if (isI18nEnabled() && body.data && updated.translationGroup) { await syncNonTranslatableFields( trx, collection, updated.id, updated.translationGroup, body.data, ); } // Side-write SEO data if provided, always hydrate for SEO-enabled collections if (body.seo && hasSeo) { const seoRepo = new SeoRepository(trx); updated.seo = await seoRepo.upsert(collection, resolvedId, body.seo); } else if (hasSeo) { const seoRepo = new SeoRepository(trx); updated.seo = await seoRepo.get(collection, resolvedId); } await hydrateBylines(trx, collection, updated); // Replace taxonomy assignments in the same transaction. Uses the // entry's own locale (post-update) to resolve slugs so an update // that also changes locale still lands on the correct term // variants. See handleContentCreate for rationale. if (body.taxonomies) { await assignTaxonomies( trx, collection, resolvedId, updated.locale ?? body.locale, body.taxonomies, ); } return updated; }); return { success: true, data: { item, _rev: encodeRev(item) }, }; } catch (error) { // Handle structured errors thrown from inside the transaction // (rev check failures, not-found) if (hasApiError(error)) { return { success: false, error: { code: error.apiError.code, message: error.message }, }; } if (isMissingTableError(error)) { return { success: false, error: { code: "COLLECTION_NOT_FOUND", message: `Collection '${collection}' not found`, }, }; } if (error instanceof EmDashValidationError) { return { success: false, error: { code: "VALIDATION_ERROR", message: error.message }, }; } const message = error instanceof Error ? error.message.toLowerCase() : ""; if (message.includes("unique constraint failed") || message.includes("duplicate key")) { if (message.includes("slug")) { return { success: false, error: { code: "SLUG_CONFLICT", message: `Slug '${body.slug ?? id}' already exists in collection '${collection}'`, }, }; } return { success: false, error: { code: "CONFLICT", message: "Unique constraint violation", }, }; } console.error("Content update error:", error); return { success: false, error: { code: "CONTENT_UPDATE_ERROR", message: "Failed to update content", }, }; } } /** * Duplicate content item. * * Only copies SEO data if the collection has SEO enabled. * Always returns consistent `seo` shape for SEO-enabled collections. */ export async function handleContentDuplicate( db: Kysely, collection: string, id: string, authorId?: string, ): Promise> { try { const hasSeo = await collectionHasSeo(db, collection); // Wrap duplicate + SEO copy in a transaction for atomicity const duplicate = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const bylineRepo = new BylineRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; const dup = await repo.duplicate(collection, resolvedId, authorId); const existingBylines = await bylineRepo.getContentBylines(collection, resolvedId); if (existingBylines.length > 0) { await bylineRepo.setContentBylines( collection, dup.id, existingBylines.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel, })), ); } if (hasSeo) { // Copy SEO data from the original (clears canonical) const seoRepo = new SeoRepository(trx); await seoRepo.copyForDuplicate(collection, resolvedId, dup.id); // Always hydrate SEO for consistent response shape dup.seo = await seoRepo.get(collection, dup.id); } await hydrateBylines(trx, collection, dup); return dup; }); return { success: true, data: { item: duplicate }, }; } catch (err) { if (err instanceof EmDashValidationError) { return { success: false, error: { code: "NOT_FOUND", message: err.message, }, }; } console.error("Content duplicate error:", err); return { success: false, error: { code: "CONTENT_DUPLICATE_ERROR", message: "Failed to duplicate content", }, }; } } /** * Delete content item (soft delete - moves to trash) */ export async function handleContentDelete( db: Kysely, collection: string, id: string, ): Promise> { try { const result = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; return { id: resolvedId, deleted: await repo.delete(collection, resolvedId), }; }); if (!result.deleted) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } return { success: true, data: { deleted: true, id: result.id }, }; } catch (error) { console.error("Content delete error:", error); return { success: false, error: { code: "CONTENT_DELETE_ERROR", message: "Failed to delete content", }, }; } } /** * Restore content item from trash */ export async function handleContentRestore( db: Kysely, collection: string, id: string, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id; return repo.restore(collection, resolvedId); }); if (!item) { return { success: false, error: { code: "NOT_FOUND", message: `Trashed content item not found: ${id}`, }, }; } return { success: true, data: { restored: true, item }, }; } catch (error) { console.error("Content restore error:", error); return { success: false, error: { code: "CONTENT_RESTORE_ERROR", message: "Failed to restore content", }, }; } } /** * Permanently delete content item (cannot be undone). * Also cleans up associated SEO data. */ export async function handleContentPermanentDelete( db: Kysely, collection: string, id: string, ): Promise> { try { const repo = new ContentRepository(db); const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id; // Wrap content delete + SEO/comment cleanup in a transaction const deleted = await withTransaction(db, async (trx) => { const trxRepo = new ContentRepository(trx); const wasDeleted = await trxRepo.permanentDelete(collection, resolvedId); if (wasDeleted) { // Clean up SEO data for permanently deleted content const seoRepo = new SeoRepository(trx); await seoRepo.delete(collection, resolvedId); // Clean up comments for permanently deleted content const commentRepo = new CommentRepository(trx); await commentRepo.deleteByContent(collection, resolvedId); // Clean up revisions for permanently deleted content const revisionRepo = new RevisionRepository(trx); await revisionRepo.deleteByEntry(collection, resolvedId); } return wasDeleted; }); if (!deleted) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } return { success: true, data: { deleted: true, id: resolvedId }, }; } catch (error) { console.error("Content permanent delete error:", error); return { success: false, error: { code: "CONTENT_DELETE_ERROR", message: "Failed to permanently delete content", }, }; } } /** * List trashed content items */ export async function handleContentListTrashed( db: Kysely, collection: string, options: { limit?: number; cursor?: string } = {}, ): Promise> { try { const repo = new ContentRepository(db); const result = await repo.findTrashed(collection, { limit: options.limit, cursor: options.cursor, }); return { success: true, data: { items: result.items.map((item) => ({ id: item.id, type: item.type, slug: item.slug, status: item.status, data: item.data, authorId: item.authorId, createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, deletedAt: item.deletedAt, })), nextCursor: result.nextCursor, }, }; } catch (error) { if (error instanceof InvalidCursorError) { return { success: false, error: { code: "INVALID_CURSOR", message: error.message }, }; } console.error("Content list trashed error:", error); return { success: false, error: { code: "CONTENT_LIST_ERROR", message: "Failed to list trashed content", }, }; } } /** * Count trashed content items */ export async function handleContentCountTrashed( db: Kysely, collection: string, ): Promise> { try { const repo = new ContentRepository(db); const count = await repo.countTrashed(collection); return { success: true, data: { count }, }; } catch (error) { console.error("Content count trashed error:", error); return { success: false, error: { code: "CONTENT_COUNT_ERROR", message: "Failed to count trashed content", }, }; } } /** * Schedule content for future publishing */ export async function handleContentSchedule( db: Kysely, collection: string, id: string, scheduledAt: string, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const existing = await repo.findByIdOrSlug(collection, id); const resolvedId = existing?.id ?? id; if (existing) { const publishConfig = await getCollectionPublishConfig(trx, collection); requireRoutablePublishSlug(publishConfig.routable, existing.slug); } return repo.schedule(collection, resolvedId, scheduledAt); }); const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); return { success: true, data: { item }, }; } catch (error) { if (error instanceof EmDashValidationError) { return { success: false, error: { code: "VALIDATION_ERROR", message: error.message, }, }; } console.error("Content schedule error:", error); return { success: false, error: { code: "CONTENT_SCHEDULE_ERROR", message: "Failed to schedule content", }, }; } } /** * Unschedule content (revert to draft) */ export async function handleContentUnschedule( db: Kysely, collection: string, id: string, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; return repo.unschedule(collection, resolvedId); }); const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); return { success: true, data: { item }, }; } catch (error) { if (error instanceof EmDashValidationError) { return { success: false, error: { code: "VALIDATION_ERROR", message: error.message, }, }; } console.error("Content unschedule error:", error); return { success: false, error: { code: "CONTENT_UNSCHEDULE_ERROR", message: "Failed to unschedule content", }, }; } } /** * Publish content immediately. * * Publication is one atomic content-row statement. On databases that support * transactions, the existing slug-redirect side write remains grouped with it. */ export async function handleContentPublish( db: Kysely, collection: string, id: string, options: { publishedAt?: string; requireScheduledDue?: boolean; expectedScheduledAt?: string; } = {}, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; const publishConfig = await getCollectionPublishConfig(trx, collection); // Capture the pre-publish state. For revision-supporting collections a // slug edit is staged as `_slug` in the draft revision and only lands // on the live `slug` column here, inside `repo.publish()` — it never // passes through handleContentUpdate, where slug-change auto-redirects // are normally created. const existing = await repo.findById(collection, resolvedId); const published = await repo.publish( collection, resolvedId, options.publishedAt, options.requireScheduledDue, options.expectedScheduledAt, publishConfig.supportsRevisions, publishConfig.routable, ); // Leave a 301 behind when publishing changed the slug of an entry that // was already published — its old URL was live and may be indexed or // linked. A first publish is excluded: a draft's URL was never public. if ( existing?.status === "published" && existing.slug && published.slug && existing.slug !== published.slug ) { await createSlugChangeRedirect(trx, collection, existing.slug, published.slug, resolvedId); } return published; }); const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); return { success: true, data: { item }, }; } catch (error) { if (error instanceof ContentMutationConflictError) { return { success: false, error: { code: "CONFLICT", message: error.message, }, }; } // The scheduled sweep gates publish on the row still being due; a row // unscheduled in the meantime is a silent skip, not a failure. if (error instanceof ScheduledNotDueError) { return { success: false, error: { code: "NOT_DUE", message: error.message, }, }; } if (error instanceof EmDashValidationError) { // The staged-slug pre-check tags its error so it maps to the same // 409 SLUG_CONFLICT as direct slug edits in create/update. const details: unknown = error.details; const isSlugConflict = typeof details === "object" && details !== null && "code" in details && details.code === "SLUG_CONFLICT"; return { success: false, error: { code: isSlugConflict ? "SLUG_CONFLICT" : "VALIDATION_ERROR", message: error.message, }, }; } // Backstop for the pre-check inside repo.publish(): a concurrent write // can still take the slug between the check and the UPDATE, in which // case the `(slug, locale)` unique constraint fires. Same fingerprint // mapping as create/update — never a raw SQLite error to the client. const message = error instanceof Error ? error.message.toLowerCase() : ""; if ( (message.includes("unique constraint failed") || message.includes("duplicate key")) && message.includes("slug") ) { return { success: false, error: { code: "SLUG_CONFLICT", message: `The staged slug is already used by another entry in collection '${collection}'`, }, }; } console.error("Content publish error:", error); return { success: false, error: { code: "CONTENT_PUBLISH_ERROR", message: "Failed to publish content", }, }; } } /** * Unpublish content (revert to draft). * * Wrapped in a transaction — unpublish may create a draft revision * from the live version then update the status, which is multi-step. */ export async function handleContentUnpublish( db: Kysely, collection: string, id: string, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; return repo.unpublish(collection, resolvedId); }); const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); return { success: true, data: { item }, }; } catch (error) { if (error instanceof EmDashValidationError) { return { success: false, error: { code: "VALIDATION_ERROR", message: error.message, }, }; } console.error("Content unpublish error:", error); return { success: false, error: { code: "CONTENT_UNPUBLISH_ERROR", message: "Failed to unpublish content", }, }; } } /** * Count scheduled content items */ export async function handleContentCountScheduled( db: Kysely, collection: string, ): Promise> { try { const repo = new ContentRepository(db); const count = await repo.countScheduled(collection); return { success: true, data: { count }, }; } catch (error) { console.error("Content count scheduled error:", error); return { success: false, error: { code: "CONTENT_COUNT_ERROR", message: "Failed to count scheduled content", }, }; } } /** * Discard draft changes (revert to live version) */ export async function handleContentDiscardDraft( db: Kysely, collection: string, id: string, ): Promise> { try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; return repo.discardDraft(collection, resolvedId); }); const hasSeo = await collectionHasSeo(db, collection); await hydrateSeo(db, collection, item, hasSeo); return { success: true, data: { item }, }; } catch (error) { if (error instanceof EmDashValidationError) { return { success: false, error: { code: "NOT_FOUND", message: error.message, }, }; } console.error("Content discard draft error:", error); return { success: false, error: { code: "CONTENT_DISCARD_DRAFT_ERROR", message: "Failed to discard draft", }, }; } } /** * Compare live and draft revisions */ export async function handleContentCompare( db: Kysely, collection: string, id: string, ): Promise< ApiResult<{ hasChanges: boolean; live: Record | null; draft: Record | null; }> > { try { const repo = new ContentRepository(db); const entry = await repo.findByIdOrSlug(collection, id); if (!entry) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } const revisionRepo = new RevisionRepository(db); const live = entry.liveRevisionId ? await revisionRepo.findById(entry.liveRevisionId) : null; const draft = entry.draftRevisionId ? await revisionRepo.findById(entry.draftRevisionId) : null; return { success: true, data: { hasChanges: entry.draftRevisionId !== null && entry.draftRevisionId !== entry.liveRevisionId, live: live?.data ?? null, draft: draft?.data ?? null, }, }; } catch (error) { console.error("Content compare error:", error); return { success: false, error: { code: "CONTENT_COMPARE_ERROR", message: "Failed to compare revisions", }, }; } } /** * Get all translations for a content item. * Returns the item's translation group members with locale and status info. */ export async function handleContentTranslations( db: Kysely, collection: string, id: string, ): Promise< ApiResult<{ translationGroup: string; translations: Array<{ id: string; locale: string | null; slug: string | null; status: string; updatedAt: string; }>; }> > { try { const repo = new ContentRepository(db); const item = await repo.findByIdOrSlug(collection, id); if (!item) { return { success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${id}`, }, }; } if (!item.translationGroup) { return { success: true, data: { translationGroup: item.id, translations: [ { id: item.id, locale: item.locale, slug: item.slug, status: item.status, updatedAt: item.updatedAt, }, ], }, }; } const translations = await repo.findTranslations(collection, item.translationGroup); return { success: true, data: { translationGroup: item.translationGroup, translations: translations.map((t) => ({ id: t.id, locale: t.locale, slug: t.slug, status: t.status, updatedAt: t.updatedAt, })), }, }; } catch (error) { if (error instanceof Error) { console.error("Content translations error:", error); } return { success: false, error: { code: "CONTENT_TRANSLATIONS_ERROR", message: "Failed to get translations", }, }; } } // --------------------------------------------------------------------------- // Non-translatable field sync // --------------------------------------------------------------------------- /** * Sync non-translatable fields to sibling locales. * * When a content item is updated and it belongs to a translation group, * any non-translatable fields in the update data are written to all other * rows in the same translation group within the same transaction. * * Non-translatable fields are **copied, not linked** — each row owns its * own data. This keeps queries simple and avoids cross-row joins. */ async function syncNonTranslatableFields( trx: Kysely, collectionSlug: string, updatedItemId: string, translationGroup: string, data: Record, ): Promise { // Get the collection to find its fields const collection = await trx .selectFrom("_emdash_collections") .select("id") .where("slug", "=", collectionSlug) .executeTakeFirst(); if (!collection) return; // Find non-translatable fields that are present in the update data const fields = await trx .selectFrom("_emdash_fields") .select("slug") .where("collection_id", "=", collection.id) .where("translatable", "=", 0) .execute(); const nonTranslatableSlugs = fields.map((f) => f.slug); if (nonTranslatableSlugs.length === 0) return; // Filter to only the non-translatable fields present in this update const syncData: Record = {}; for (const slug of nonTranslatableSlugs) { if (slug in data) { syncData[slug] = data[slug]; } } if (Object.keys(syncData).length === 0) return; // Build the SET clause for sibling rows validateIdentifier(collectionSlug, "collection slug"); const tableName = `ec_${collectionSlug}`; // Update all sibling rows (same translation_group, different id) const setClauses = Object.entries(syncData).map(([key, value]) => { validateIdentifier(key, "field slug"); const serialized = typeof value === "object" && value !== null ? JSON.stringify(value) : value; return sql`${sql.ref(key)} = ${serialized}`; }); await sql` UPDATE ${sql.ref(tableName)} SET ${sql.join(setClauses, sql`, `)} WHERE translation_group = ${translationGroup} AND id != ${updatedItemId} `.execute(trx); } /** * Resolve a `{ taxonomyName: [slug, ...] }` map to term IDs and replace the * entry's assignments for each named taxonomy. * * Shared by handleContentCreate and handleContentUpdate so both MCP entry * points behave identically. Slug resolution is scoped to `locale`; passing * `undefined` lets `findBySlug` fall back to its default (lowest locale code) * so callers on single-locale sites don't need to know the site's default. * * Throws EmDashValidationError on unknown slug or wrong shape; the calling * handler translates that into a VALIDATION_ERROR response. */ async function assignTaxonomies( trx: Kysely, collection: string, entryId: string, locale: string | undefined, taxonomies: Record, ): Promise { const taxRepo = new TaxonomyRepository(trx); let anyChange = false; for (const [taxonomyName, slugs] of Object.entries(taxonomies)) { if (!Array.isArray(slugs)) { throw new EmDashValidationError(`taxonomies.${taxonomyName} must be an array of term slugs`); } const termIds: string[] = []; for (const slug of slugs) { if (typeof slug !== "string" || slug.length === 0) { throw new EmDashValidationError( `taxonomies.${taxonomyName} contains a non-string or empty slug`, ); } const term = await taxRepo.findBySlug(taxonomyName, slug, locale); if (!term) { throw new EmDashValidationError( `Unknown taxonomy term: ${taxonomyName}='${slug}'${ locale ? ` (locale '${locale}')` : "" }`, ); } termIds.push(term.id); } await taxRepo.setTermsForEntry(collection, entryId, taxonomyName, termIds); anyChange = true; } // Match the REST route's behaviour: taxonomy term assignments changed, // so invalidate the taxonomy object cache used during hydration. if (anyChange) invalidateTermCache(); }