From a52ab1bd79335baa257b856b5389b368cfa0b44c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 03:20:36 +0200 Subject: [PATCH 1/2] test(core): cover term assignments on permanent delete Term assignments are keyed by the entry's translation group. Permanently deleting an entry removes its row, SEO data, comments and revisions, but not the assignments, so once the group's last row is gone nothing owns them. Against the current handler, permanently deleting a post that has no translations leaves its assignment behind, and so does deleting both translations of a translated post. A third case pins that a translation in the trash keeps the assignments, since restoring it brings its terms back. --- .../content/permanent-delete-terms.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/core/tests/integration/content/permanent-delete-terms.test.ts diff --git a/packages/core/tests/integration/content/permanent-delete-terms.test.ts b/packages/core/tests/integration/content/permanent-delete-terms.test.ts new file mode 100644 index 0000000000..e4732f9c62 --- /dev/null +++ b/packages/core/tests/integration/content/permanent-delete-terms.test.ts @@ -0,0 +1,91 @@ +/** + * Term assignments belong to the translation group, so permanent delete + * removes them only with the group's last row, trashed rows included. + */ + +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { handleContentPermanentDelete } from "../../../src/api/handlers/content.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("handleContentPermanentDelete: term assignments", (dialect) => { + let ctx: DialectTestContext; + let content: ContentRepository; + let taxonomies: TaxonomyRepository; + let termId: string; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + content = new ContentRepository(ctx.db); + taxonomies = new TaxonomyRepository(ctx.db); + const term = await taxonomies.create({ name: "tag", slug: "news", label: "News" }); + termId = term.id; + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function assignmentsFor(group: string) { + return ctx.db + .selectFrom("content_taxonomies") + .select("taxonomy_id") + .where("collection", "=", "post") + .where("entry_id", "=", group) + .execute(); + } + + async function trashAndPurge(id: string) { + expect(await content.delete("post", id)).toBe(true); + const result = await handleContentPermanentDelete(ctx.db, "post", id); + expect(result.success).toBe(true); + } + + async function createTranslatedPost() { + const en = await content.create({ type: "post", locale: "en", data: { title: "Hello" } }); + const de = await content.create({ + type: "post", + locale: "de", + translationOf: en.id, + data: { title: "Hallo" }, + }); + await taxonomies.attachToEntry("post", en.id, termId); + return { en, de, group: en.translationGroup! }; + } + + it("removes the assignments when the last translation is deleted", async () => { + const post = await content.create({ type: "post", data: { title: "Only" } }); + await taxonomies.attachToEntry("post", post.id, termId); + expect(await assignmentsFor(post.translationGroup!)).toHaveLength(1); + + await trashAndPurge(post.id); + + expect(await assignmentsFor(post.translationGroup!)).toEqual([]); + }); + + it("keeps the assignments while another translation exists", async () => { + const { en, de, group } = await createTranslatedPost(); + + await trashAndPurge(en.id); + expect(await assignmentsFor(group)).toHaveLength(1); + + await trashAndPurge(de.id); + expect(await assignmentsFor(group)).toEqual([]); + }); + + it("keeps the assignments while another translation is in the trash", async () => { + const { en, de, group } = await createTranslatedPost(); + expect(await content.delete("post", de.id)).toBe(true); + + await trashAndPurge(en.id); + + expect(await assignmentsFor(group)).toHaveLength(1); + }); +}); From f435c319cba75d2707fe56181c7d97ce6de40bcd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 03:20:36 +0200 Subject: [PATCH 2/2] fix(core): remove taxonomy assignments when content is permanently deleted Term assignments belong to the translation group rather than to a single locale row, so the permanent-delete handler now removes them only when no row of the group is left. A translation in the trash counts as left: it can be restored, and its terms with it. The removal takes the group directly instead of an entry id, because the entry-id variant resolves the group through a row that no longer exists at that point. --- .changeset/purge-entry-term-assignments.md | 5 +++++ packages/core/src/api/handlers/content.ts | 16 ++++++++++++++++ .../core/src/database/repositories/content.ts | 13 +++++++++++++ .../core/src/database/repositories/taxonomy.ts | 8 ++++++++ 4 files changed, 42 insertions(+) create mode 100644 .changeset/purge-entry-term-assignments.md diff --git a/.changeset/purge-entry-term-assignments.md b/.changeset/purge-entry-term-assignments.md new file mode 100644 index 0000000000..fe9a900981 --- /dev/null +++ b/.changeset/purge-entry-term-assignments.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes permanently deleting an entry leaving its taxonomy term assignments in the database. The assignments are removed with the last translation of the entry and kept while another translation exists, including one in the trash. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index aa7ad83f87..f4317b63a0 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -1389,6 +1389,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) { @@ -1402,6 +1403,21 @@ export async function handleContentPermanentDelete( const revisionRepo = new RevisionRepository(trx); await revisionRepo.deleteByEntry(collection, resolvedId); await new EntryLockRepository(trx).releaseEntry(collection, resolvedId); + // Term assignments are keyed by translation_group, so they belong to the + // group rather than to this row. They go only once no row of the group is + // left, trashed ones included, since a trashed row can still be restored. + if (item?.translationGroup) { + const groupSurvives = await trxRepo.hasTranslationsIncludingTrashed( + collection, + item.translationGroup, + ); + if (!groupSurvives) { + await new TaxonomyRepository(trx).clearEntryGroupTerms( + collection, + item.translationGroup, + ); + } + } } return wasDeleted; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 8d44967c40..a3696c2a34 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1665,6 +1665,19 @@ export class ContentRepository { return result.rows.map((row) => this.mapRow(type, row)); } + /** Whether any row of `translationGroup` exists, trashed rows included. */ + 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/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts index d6c5bb9bd1..f6e6ec4e98 100644 --- a/packages/core/src/database/repositories/taxonomy.ts +++ b/packages/core/src/database/repositories/taxonomy.ts @@ -749,7 +749,15 @@ export class TaxonomyRepository { async clearEntryTerms(collection: string, entryId: string): Promise { const entryGroup = await this.resolveEntryTranslationGroup(collection, entryId); if (!entryGroup) return 0; + return this.clearEntryGroupTerms(collection, entryGroup); + } + /** + * Remove every term assignment held by an entry translation group. Takes the + * group rather than an entry id, so it still works after the group's last + * row has been deleted. + */ + async clearEntryGroupTerms(collection: string, entryGroup: string): Promise { const result = await this.db .deleteFrom("content_taxonomies") .where("collection", "=", collection)