{"version":3,"file":"taxonomy-D7AkTRxK.mjs","names":[],"sources":["../src/database/repositories/taxonomy.ts"],"sourcesContent":["import { sql, type Kysely, type Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { invalidateTaxonomyObjectCache } from \"../../object-cache/index.js\";\nimport { slugify } from \"../../utils/slugify.js\";\nimport { withTransaction } from \"../transaction.js\";\nimport type { Database, TaxonomyTable } from \"../types.js\";\nimport { validateIdentifier } from \"../validate.js\";\n\n/** A member of one sibling group and the position it currently holds. */\nexport interface SiblingPosition {\n\tgroup: string;\n\tposition: number;\n}\n\n/**\n * Translation groups per reorder `UPDATE`. Each costs three bound parameters —\n * a CASE `WHEN`/`THEN` pair plus one slot in the `IN` list — which keeps a\n * statement inside D1's 100-parameter ceiling.\n */\nconst GROUPS_PER_UPDATE = 32;\nconst NUMERIC_SUFFIX_PATTERN = /^\\d+$/;\n\n/** Deal the listed groups back out over the slots they hold, in the order given. */\nfunction permuteWithinSlots(\n\tlisted: readonly string[],\n\toccupied: readonly number[],\n): Map<string, number> {\n\tconst slots = occupied.toSorted((a, b) => a - b);\n\tconst target = new Map<string, number>();\n\tlisted.forEach((group, index) => {\n\t\tconst position = slots[index];\n\t\tif (position !== undefined) target.set(group, position);\n\t});\n\treturn target;\n}\n\n/**\n * Renumber a whole sibling group 0..n-1, with the listed groups in the order\n * given and every other member left in the place it already held.\n *\n * Works off each member's index in the sequence rather than its stored value,\n * which is what lets it resolve positions that tie. The sort is stable, so\n * tied members keep the order `siblings` arrives in — deterministic, but a\n * listing that mixes locales breaks a tie on whichever locale's label sorts\n * first, not on the order any one caller rendered.\n */\nfunction renumberSiblings(\n\tlisted: readonly string[],\n\tsiblings: readonly SiblingPosition[],\n): Map<string, number> {\n\tconst sequence = siblings.toSorted((a, b) => a.position - b.position);\n\tconst wanted = new Set(listed);\n\tconst target = new Map<string, number>();\n\tlet next = 0;\n\tsequence.forEach(({ group }, index) => {\n\t\tconst replacement = wanted.has(group) ? listed[next++] : undefined;\n\t\ttarget.set(replacement ?? group, index);\n\t});\n\treturn target;\n}\n\nexport interface Taxonomy {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdata: Record<string, unknown> | null;\n\tlocale: string;\n\ttranslationGroup: string | null;\n\t/**\n\t * Position among siblings. Shared by every row of a `translation_group` —\n\t * a term sits in the same place in every locale it is translated into.\n\t */\n\tsortOrder: number;\n}\n\nexport interface CreateTaxonomyInput {\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId?: string;\n\tdata?: Record<string, unknown>;\n\t/** Omit to let the DB default (current value: 'en') apply. Higher layers\n\t * resolve the locale from the request context / i18n config. */\n\tlocale?: string;\n\t/** When set, links the new term into the source term's translation_group. */\n\ttranslationOf?: string;\n}\n\nexport interface UpdateTaxonomyInput {\n\tslug?: string;\n\tlabel?: string;\n\tparentId?: string | null;\n\tdata?: Record<string, unknown>;\n}\n\nexport interface FindOptions {\n\tparentId?: string | null;\n\tlocale?: string;\n}\n\nexport interface TaxonomyManualPageCursor {\n\tsortOrder: number;\n\tlabel: string;\n\tid: string;\n}\n\nexport interface TaxonomyPageOptions extends FindOptions {\n\tcursor?: TaxonomyManualPageCursor;\n\tlimit?: number;\n}\n\nexport interface TaxonomyPage {\n\titems: Taxonomy[];\n\thasMore: boolean;\n}\n\nexport interface TaxonomyAssignmentTranslation {\n\tid: string;\n\tslug: string;\n\tlocale: string;\n}\n\nexport interface TaxonomyAssignmentResolution {\n\ttranslationGroup: string;\n\tterm: Taxonomy | null;\n\tavailableLocales: string[];\n\ttranslations: TaxonomyAssignmentTranslation[];\n}\n\n/**\n * Taxonomy repository for categories, tags, and other classification.\n *\n * Terms are per-locale. Translations of the same term share a `translation_group`\n * ULID. `content_taxonomies` stores translation_groups on both sides so a single\n * association spans every locale of a post and term.\n *\n * Strict lookup methods use only the locale callers supply. The explicitly\n * resolved methods accept both the preferred and default locales so their\n * fallback policy stays visible at the call site.\n *\n * `sort_order` is per translation_group, not per row: every row sharing a\n * translation_group carries the same value, so a term holds one position across\n * all its locales. Sibling groups are keyed on the raw `parent_id` column, which\n * is locale-agnostic for the same reason (it stores the parent's\n * translation_group). Writes must preserve both invariants.\n */\nexport class TaxonomyRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Create a new taxonomy term. When `translationOf` is set the new row joins\n\t * the source term's translation_group; otherwise a fresh group is minted\n\t * (matching the migration backfill pattern `translation_group = id`).\n\t */\n\tasync create(input: CreateTaxonomyInput): Promise<Taxonomy> {\n\t\tconst id = ulid();\n\n\t\t// Empty-string parentId is coerced to null defensively. Higher layers\n\t\t// also normalize this — see handleTermCreate / handleTermUpdate.\n\t\t// `parent_id` stores the parent's locale-agnostic translation_group (not a\n\t\t// row id), mirroring content_taxonomies.taxonomy_id, so a child stays\n\t\t// nested in every locale's tree. resolveTranslationGroup accepts either a\n\t\t// row id or an already-resolved group, so this is idempotent.\n\t\tconst parentInput =\n\t\t\tinput.parentId === undefined || input.parentId === \"\" ? null : input.parentId;\n\t\tconst parentId = parentInput ? await this.resolveParentRef(parentInput) : null;\n\n\t\tlet translationGroup = id;\n\t\tlet sortOrder: number | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await this.findById(input.translationOf);\n\t\t\tif (source?.translationGroup) translationGroup = source.translationGroup;\n\t\t\t// A translation is the same term in another locale, so it takes the\n\t\t\t// group's position — but only while it stays in the group that position\n\t\t\t// belongs to. Landing under a different parent makes it a new member of\n\t\t\t// that sibling group, and the source's position means nothing there.\n\t\t\tif (source && source.parentId === parentId) sortOrder = source.sortOrder;\n\t\t}\n\t\tsortOrder ??= await this.nextSortOrder(input.name, parentId);\n\n\t\tawait this.db\n\t\t\t.insertInto(\"taxonomies\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tslug: input.slug,\n\t\t\t\tlabel: input.label,\n\t\t\t\tparent_id: parentId,\n\t\t\t\tdata: input.data ? JSON.stringify(input.data) : null,\n\t\t\t\tsort_order: sortOrder,\n\t\t\t\t// When omitted, the DB DEFAULT 'en' is used — keeps behaviour\n\t\t\t\t// consistent with ContentRepository and lets higher layers\n\t\t\t\t// supply an explicit locale from request context.\n\t\t\t\t...(input.locale !== undefined ? { locale: input.locale } : {}),\n\t\t\t\ttranslation_group: translationGroup,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tinvalidateTaxonomyObjectCache();\n\n\t\tconst taxonomy = await this.findById(id);\n\t\tif (!taxonomy) throw new Error(\"Failed to create taxonomy\");\n\t\treturn taxonomy;\n\t}\n\n\tasync findById(id: string): Promise<Taxonomy | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/**\n\t * Find a term by (name, slug). When `locale` is provided, filter by it.\n\t * When omitted, returns the lowest-locale-code match (deterministic across\n\t * calls). Mirrors `ContentRepository.findBySlug`.\n\t */\n\tasync findBySlug(name: string, slug: string, locale?: string): Promise<Taxonomy | null> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"slug\", \"=\", slug);\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst row = await query.orderBy(\"locale\", \"asc\").executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/** Generate a locale-scoped term slug, adding a numeric suffix when needed. */\n\tasync generateUniqueSlug(name: string, text: string, locale?: string): Promise<string> {\n\t\tconst baseSlug = slugify(text);\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"slug\")\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where((eb) => eb.or([eb(\"slug\", \"=\", baseSlug), eb(\"slug\", \"like\", `${baseSlug}-%`)]));\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst candidates = await query.execute();\n\t\tif (!candidates.some((candidate) => candidate.slug === baseSlug)) return baseSlug;\n\n\t\tlet maxSuffix = 0;\n\t\tconst prefix = `${baseSlug}-`;\n\t\tfor (const candidate of candidates) {\n\t\t\tif (!candidate.slug.startsWith(prefix)) continue;\n\t\t\tconst suffix = candidate.slug.slice(prefix.length);\n\t\t\tif (!NUMERIC_SUFFIX_PATTERN.test(suffix)) continue;\n\t\t\tmaxSuffix = Math.max(maxSuffix, Number.parseInt(suffix, 10));\n\t\t}\n\t\treturn `${baseSlug}-${maxSuffix + 1}`;\n\t}\n\n\t/**\n\t * Get all terms for a taxonomy (e.g., all categories).\n\t *\n\t * `sort_order` carries the manual order set from the admin; it is 0 for\n\t * terms nobody has reordered, so an untouched taxonomy still comes back\n\t * alphabetically. `id asc` is a stable tiebreaker for terms that share both\n\t * values. Without it the SQL ordering is implementation-defined when they match.\n\t */\n\tasync findByName(name: string, options: FindOptions = {}): Promise<Taxonomy[]> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tif (options.parentId === null) {\n\t\t\t\tquery = query.where(\"parent_id\", \"is\", null);\n\t\t\t} else {\n\t\t\t\tquery = query.where(\"parent_id\", \"=\", options.parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync findByNameResolved(\n\t\tname: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst locales = [...new Set([locale, defaultLocale])];\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"locale\", \"in\", locales)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst selected = new Map<string, Taxonomy>();\n\t\tfor (const row of rows) {\n\t\t\tconst term = this.rowToTaxonomy(row);\n\t\t\tconst group = term.translationGroup ?? term.id;\n\t\t\tconst current = selected.get(group);\n\t\t\tif (!current || term.locale === locale) selected.set(group, term);\n\t\t}\n\t\treturn [...selected.values()].toSorted(\n\t\t\t(a, b) =>\n\t\t\t\ta.sortOrder - b.sortOrder || a.label.localeCompare(b.label) || a.id.localeCompare(b.id),\n\t\t);\n\t}\n\n\tasync findPageByName(name: string, options: TaxonomyPageOptions = {}): Promise<TaxonomyPage> {\n\t\tconst limit = Math.max(1, Math.min(options.limit ?? 50, 100));\n\t\tlet query = this.db.selectFrom(\"taxonomies\").selectAll().where(\"name\", \"=\", name);\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tquery =\n\t\t\t\toptions.parentId === null\n\t\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t\t: query.where(\"parent_id\", \"=\", options.parentId);\n\t\t}\n\n\t\tif (options.cursor) {\n\t\t\tconst cursor = options.cursor;\n\t\t\tquery = query.where((eb) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"sort_order\", \">\", cursor.sortOrder),\n\t\t\t\t\teb.and([eb(\"sort_order\", \"=\", cursor.sortOrder), eb(\"label\", \">\", cursor.label)]),\n\t\t\t\t\teb.and([\n\t\t\t\t\t\teb(\"sort_order\", \"=\", cursor.sortOrder),\n\t\t\t\t\t\teb(\"label\", \"=\", cursor.label),\n\t\t\t\t\t\teb(\"id\", \">\", cursor.id),\n\t\t\t\t\t]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\t\tquery = query.orderBy(\"sort_order\", \"asc\").orderBy(\"label\", \"asc\").orderBy(\"id\", \"asc\");\n\n\t\tconst rows = await query.limit(limit + 1).execute();\n\t\treturn {\n\t\t\titems: rows.slice(0, limit).map((row) => this.rowToTaxonomy(row)),\n\t\t\thasMore: rows.length > limit,\n\t\t};\n\t}\n\n\t/**\n\t * Children of a term. Accepts a term id OR a translation_group and resolves\n\t * to the group, since `parent_id` stores the parent's translation_group.\n\t * Pass `locale` to scope to one locale's tree (children share the parent's\n\t * group across locales); omit it to find children in every locale (used to\n\t * block deletes that would orphan a sibling translation's subtree).\n\t */\n\tasync findChildren(parentIdOrGroup: string, locale?: string): Promise<Taxonomy[]> {\n\t\tconst group = await this.resolveTranslationGroup(parentIdOrGroup);\n\t\tif (!group) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"parent_id\", \"=\", group)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\t/**\n\t * Every translation sibling of a term (including itself), identified by\n\t * their shared `translation_group`.\n\t */\n\tasync findTranslations(translationGroup: string): Promise<Taxonomy[]> {\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", translationGroup)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync update(id: string, input: UpdateTaxonomyInput): Promise<Taxonomy | null> {\n\t\tconst existing = await this.findById(id);\n\t\tif (!existing) return null;\n\n\t\t// Per-row display fields. `parent_id` and `sort_order` are not here: both\n\t\t// belong to the translation_group and are written across it below.\n\t\tconst updates: Record<string, unknown> = {};\n\t\tif (input.slug !== undefined) updates.slug = input.slug;\n\t\tif (input.label !== undefined) updates.label = input.label;\n\t\tif (input.data !== undefined) updates.data = JSON.stringify(input.data);\n\n\t\tconst group: { parent_id?: string | null; sort_order?: number } = {};\n\t\tif (input.parentId !== undefined) {\n\t\t\t// Defense in depth: empty-string parentId means null (no parent).\n\t\t\t// Otherwise persist the parent's translation_group (locale-agnostic),\n\t\t\t// matching create() — see the note there.\n\t\t\tconst parentId =\n\t\t\t\tinput.parentId === \"\" || input.parentId === null\n\t\t\t\t\t? null\n\t\t\t\t\t: await this.resolveParentRef(input.parentId);\n\n\t\t\tif (parentId !== existing.parentId) {\n\t\t\t\tgroup.parent_id = parentId;\n\t\t\t\t// A position only means anything within one sibling group, so a term\n\t\t\t\t// that changes parent is appended to the group it lands in.\n\t\t\t\tgroup.sort_order = await this.nextSortOrder(existing.name, parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst hasRowUpdates = Object.keys(updates).length > 0;\n\t\tconst hasGroupUpdates = Object.keys(group).length > 0;\n\t\tif (hasRowUpdates || hasGroupUpdates) {\n\t\t\tawait withTransaction(this.db, async (trx) => {\n\t\t\t\tif (hasRowUpdates) {\n\t\t\t\t\tawait trx.updateTable(\"taxonomies\").set(updates).where(\"id\", \"=\", id).execute();\n\t\t\t\t}\n\t\t\t\tif (hasGroupUpdates) {\n\t\t\t\t\tawait trx\n\t\t\t\t\t\t.updateTable(\"taxonomies\")\n\t\t\t\t\t\t.set(group)\n\t\t\t\t\t\t.where(\"translation_group\", \"=\", existing.translationGroup ?? existing.id)\n\t\t\t\t\t\t.execute();\n\t\t\t\t}\n\t\t\t});\n\t\t\tinvalidateTaxonomyObjectCache();\n\t\t}\n\n\t\treturn this.findById(id);\n\t}\n\n\t/**\n\t * Move `groups` (translation_groups, in the desired order) into the positions\n\t * those same groups already occupy, leaving every other member of the sibling\n\t * group where it is.\n\t *\n\t * `groups` may be a subset: a locale renders only the terms translated into\n\t * it, so an admin in `fr` often cannot name every member. A group left out\n\t * keeps its place, which is also what makes a stale list harmless.\n\t *\n\t * `siblings` is every member of the group with the position it holds, in a\n\t * listing's order — tied positions are resolved by that order, so pass it as\n\t * read. Groups already at their target are skipped, so one swap rewrites two\n\t * groups rather than the whole list.\n\t */\n\tasync reorder(groups: string[], siblings: readonly SiblingPosition[]): Promise<void> {\n\t\tconst current = new Map(siblings.map(({ group, position }) => [group, position]));\n\n\t\tconst listed: string[] = [];\n\t\tconst occupied: number[] = [];\n\t\tfor (const group of groups) {\n\t\t\tconst position = current.get(group);\n\t\t\tif (position === undefined) continue;\n\t\t\tlisted.push(group);\n\t\t\toccupied.push(position);\n\t\t}\n\t\tif (listed.length === 0) return;\n\n\t\t// Tied positions have no distinct order to permute into, so the requested\n\t\t// one would be dropped without a word. Renumbering is the only way to\n\t\t// honour it, and it repairs the tie on the way through.\n\t\tconst target =\n\t\t\tnew Set(occupied).size === occupied.length\n\t\t\t\t? permuteWithinSlots(listed, occupied)\n\t\t\t\t: renumberSiblings(listed, siblings);\n\n\t\tconst changed = [...target].filter(([group, position]) => current.get(group) !== position);\n\t\tif (changed.length === 0) return;\n\n\t\tawait this.applyPositions(changed);\n\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Write one position per translation_group, GROUPS_PER_UPDATE at a time so\n\t * each statement stays inside D1's parameter ceiling.\n\t *\n\t * D1 has no transactions — `withTransaction` runs its callback bare there —\n\t * so a chunk is the unit that can't tear. A reorder spanning several chunks\n\t * can, and leaves ties, which the next reorder renumbers away.\n\t */\n\tprivate async applyPositions(positions: readonly (readonly [string, number])[]): Promise<void> {\n\t\tfor (let index = 0; index < positions.length; index += GROUPS_PER_UPDATE) {\n\t\t\tconst chunk = positions.slice(index, index + GROUPS_PER_UPDATE);\n\t\t\t// The CAST types the bound position. Postgres resolves a CASE whose THEN\n\t\t\t// arms are all untyped parameters to text, then refuses to assign text to\n\t\t\t// an integer column.\n\t\t\tconst arms = sql.join(\n\t\t\t\tchunk.map(([group, position]) => sql`WHEN ${group} THEN CAST(${position} AS INTEGER)`),\n\t\t\t\tsql` `,\n\t\t\t);\n\t\t\tconst keys = sql.join(chunk.map(([group]) => sql`${group}`));\n\t\t\tawait sql`\n\t\t\t\tUPDATE taxonomies\n\t\t\t\tSET sort_order = CASE translation_group ${arms} END\n\t\t\t\tWHERE translation_group IN (${keys})\n\t\t\t`.execute(this.db);\n\t\t}\n\t}\n\n\t/**\n\t * Position for a term joining a sibling group: one past the last member, or\n\t * 0 when the group is empty.\n\t *\n\t * Bounds are taken across every locale because a position belongs to the\n\t * translation_group, not to a row — a term translated into only one locale\n\t * still occupies its slot for all of them.\n\t */\n\tprivate async nextSortOrder(name: string, parentId: string | null): Promise<number> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select((eb) => eb.fn.max(\"sort_order\").as(\"max\"))\n\t\t\t.where(\"name\", \"=\", name);\n\t\tquery =\n\t\t\tparentId === null\n\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t: query.where(\"parent_id\", \"=\", parentId);\n\n\t\tconst bounds = await query.executeTakeFirst();\n\t\t// Null only when the group is empty.\n\t\tif (!bounds || bounds.max === null) return 0;\n\t\treturn bounds.max + 1;\n\t}\n\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst term = await this.findById(id);\n\t\tif (!term) return false;\n\n\t\t// When deleting the last translation of a group the pivot rows that\n\t\t// reference that translation_group become orphaned — purge them.\n\t\tif (term.translationGroup) {\n\t\t\tconst siblings = await this.db\n\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"translation_group\", \"=\", term.translationGroup)\n\t\t\t\t.where(\"id\", \"!=\", id)\n\t\t\t\t.execute();\n\t\t\tif (siblings.length === 0) {\n\t\t\t\tawait this.db\n\t\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t\t.where(\"taxonomy_id\", \"=\", term.translationGroup)\n\t\t\t\t\t.execute();\n\t\t\t}\n\t\t}\n\n\t\tconst result = await this.db.deleteFrom(\"taxonomies\").where(\"id\", \"=\", id).executeTakeFirst();\n\t\tinvalidateTaxonomyObjectCache();\n\t\treturn (result.numDeletedRows ?? 0n) > 0n;\n\t}\n\n\t// --- Content-Taxonomy Junction (both ids store translation_groups) ---\n\n\tasync attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst taxonomyGroup = await this.resolveTranslationGroup(taxonomyId);\n\t\tif (!taxonomyGroup) return;\n\t\tawait this.attachGroupsToEntry(collection, entryId, [taxonomyGroup]);\n\t}\n\n\t/**\n\t * Attach already-resolved term translation groups in one insert and return\n\t * the number of assignments that did not already exist.\n\t */\n\tasync attachGroupsToEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyGroups: string[],\n\t): Promise<number> {\n\t\tconst uniqueGroups = [...new Set(taxonomyGroups)];\n\t\tif (uniqueGroups.length === 0) return 0;\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t.values(\n\t\t\t\tuniqueGroups.map((taxonomy_id) => ({\n\t\t\t\t\tcollection,\n\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\ttaxonomy_id,\n\t\t\t\t})),\n\t\t\t)\n\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t.executeTakeFirst();\n\t\tconst inserted = Number(result.numInsertedOrUpdatedRows ?? 0n);\n\t\tif (inserted > 0) invalidateTaxonomyObjectCache();\n\t\treturn inserted;\n\t}\n\n\tasync detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst [entryGroup, taxonomyGroup] = await Promise.all([\n\t\t\tthis.resolveEntryTranslationGroup(collection, entryId),\n\t\t\tthis.resolveTranslationGroup(taxonomyId),\n\t\t]);\n\t\tif (!entryGroup || !taxonomyGroup) return;\n\n\t\tawait this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomy_id\", \"=\", taxonomyGroup)\n\t\t\t.execute();\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Taxonomy terms assigned to a content entry, resolved into a specific locale.\n\t * Terms whose translation_group lacks a row in the requested locale are\n\t * omitted — callers wanting fallback behaviour apply it themselves.\n\t */\n\tasync getTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName?: string,\n\t\tlocale?: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup);\n\n\t\tif (taxonomyName) query = query.where(\"taxonomies.name\", \"=\", taxonomyName);\n\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\n\t\tconst rows = await query.orderBy(\"taxonomies.locale\", \"asc\").execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync getTermAssignmentsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<TaxonomyAssignmentResolution[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.select(\"content_taxonomies.taxonomy_id as assignment_group\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.orderBy(\"content_taxonomies.taxonomy_id\", \"asc\")\n\t\t\t.orderBy(\"taxonomies.locale\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst byGroup = new Map<string, Taxonomy[]>();\n\t\tfor (const row of rows) {\n\t\t\tconst variants = byGroup.get(row.assignment_group) ?? [];\n\t\t\tvariants.push(this.rowToTaxonomy(row));\n\t\t\tbyGroup.set(row.assignment_group, variants);\n\t\t}\n\n\t\treturn Array.from(byGroup, ([translationGroup, variants]) => {\n\t\t\tconst term =\n\t\t\t\tvariants.find((variant) => variant.locale === locale) ??\n\t\t\t\tvariants.find((variant) => variant.locale === defaultLocale) ??\n\t\t\t\tnull;\n\t\t\treturn {\n\t\t\t\ttranslationGroup,\n\t\t\t\tterm,\n\t\t\t\tavailableLocales: variants.map((variant) => variant.locale),\n\t\t\t\ttranslations: variants.map((variant) => ({\n\t\t\t\t\tid: variant.id,\n\t\t\t\t\tslug: variant.slug,\n\t\t\t\t\tlocale: variant.locale,\n\t\t\t\t})),\n\t\t\t};\n\t\t});\n\t}\n\n\t/**\n\t * Replace all assignments of a given taxonomy for one content entry.\n\t * Term ids OR translation_groups are accepted and normalised to groups.\n\t */\n\tasync setTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\ttermIds: string[],\n\t): Promise<void> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return;\n\n\t\tconst groups: string[] = [];\n\t\tfor (const id of termIds) {\n\t\t\tconst group = await this.resolveTranslationGroup(id);\n\t\t\tif (group) groups.push(group);\n\t\t}\n\t\tconst newGroups = new Set(groups);\n\n\t\tconst current = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.select([\"content_taxonomies.taxonomy_id as group\"])\n\t\t\t.distinct()\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.execute();\n\t\tconst currentGroups = new Set(current.map((r) => r.group));\n\n\t\tconst toRemove = [...currentGroups].filter((g) => !newGroups.has(g));\n\t\tif (toRemove.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t\t.where(\"taxonomy_id\", \"in\", toRemove)\n\t\t\t\t.execute();\n\t\t}\n\n\t\tconst toAdd = [...newGroups].filter((g) => !currentGroups.has(g));\n\t\tif (toAdd.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t\t.values(\n\t\t\t\t\ttoAdd.map((taxonomy_id) => ({\n\t\t\t\t\t\tcollection,\n\t\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\t\ttaxonomy_id,\n\t\t\t\t\t})),\n\t\t\t\t)\n\t\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t\t.execute();\n\t\t}\n\n\t\tif (toRemove.length > 0 || toAdd.length > 0) invalidateTaxonomyObjectCache();\n\t}\n\n\tasync clearEntryTerms(collection: string, entryId: string): Promise<number> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.executeTakeFirst();\n\t\tconst removed = Number(result.numDeletedRows ?? 0);\n\t\tif (removed > 0) invalidateTaxonomyObjectCache();\n\t\treturn removed;\n\t}\n\n\tprivate async resolveEntryTranslationGroup(\n\t\tcollection: string,\n\t\tentryIdOrGroup: string,\n\t): Promise<string | null> {\n\t\tvalidateIdentifier(collection, \"collection type\");\n\t\tconst tableName = `ec_${collection}`;\n\t\tconst result = await sql<{ translation_group: string }>`\n\t\t\tSELECT translation_group\n\t\t\tFROM ${sql.ref(tableName)}\n\t\t\tWHERE id = ${entryIdOrGroup} OR translation_group = ${entryIdOrGroup}\n\t\t\tLIMIT 1\n\t\t`.execute(this.db);\n\t\treturn result.rows[0]?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Count content entries that use any translation of this term. Accepts\n\t * either a term id or a translation_group — we normalise to the group.\n\t *\n\t * Counts raw pivot rows regardless of the entry's status or deletion —\n\t * drafts and trashed entries are included. User-facing counts (admin term\n\t * list/get, public widget and term pages) use `fetchVisibleTermCounts`\n\t * from `taxonomies/term-counts.ts` instead, which counts only publicly\n\t * visible entries.\n\t */\n\tasync countEntriesWithTerm(termIdOrGroup: string): Promise<number> {\n\t\tconst group = await this.resolveTranslationGroup(termIdOrGroup);\n\t\tif (!group) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select((eb) => eb.fn.count(\"entry_id\").as(\"count\"))\n\t\t\t.where(\"taxonomy_id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Resolve a parent reference (a row id or a translation_group) to the value\n\t * persisted in `parent_id`: the parent's translation_group, which is\n\t * locale-agnostic so the child stays nested in every locale. A\n\t * translation_group normally equals its anchor row's id, which satisfies the\n\t * self-FK on `parent_id`. If that anchor row is missing (a translation whose\n\t * anchor was deleted), fall back to the id we were given so we never write a\n\t * dangling FK value.\n\t */\n\tprivate async resolveParentRef(idOrGroup: string): Promise<string> {\n\t\tconst group = await this.resolveTranslationGroup(idOrGroup);\n\t\tif (!group) return idOrGroup;\n\t\tconst anchor = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn anchor ? group : idOrGroup;\n\t}\n\n\tprivate async resolveTranslationGroup(idOrGroup: string): Promise<string | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select([\"translation_group\"])\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\treturn row?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Batch count entries for multiple taxonomy translation_groups.\n\t * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.\n\t * Returns a Map from translation_group to count.\n\t *\n\t * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`\n\t * stores the translation_group so a single assignment spans every locale.\n\t *\n\t * Like `countEntriesWithTerm`, this counts raw pivot rows regardless of\n\t * status/deletion; user-facing counts go through `fetchVisibleTermCounts`.\n\t */\n\tasync countEntriesForTerms(translationGroups: string[]): Promise<Map<string, number>> {\n\t\tif (translationGroups.length === 0) return new Map();\n\n\t\tconst { chunks, SQL_BATCH_SIZE } = await import(\"../../utils/chunks.js\");\n\n\t\tconst counts = new Map<string, number>();\n\t\tfor (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {\n\t\t\tconst rows = await this.db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.select([\"taxonomy_id\", (eb) => eb.fn.count(\"entry_id\").as(\"count\")])\n\t\t\t\t.where(\"taxonomy_id\", \"in\", chunk)\n\t\t\t\t.groupBy(\"taxonomy_id\")\n\t\t\t\t.execute();\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tcounts.set(row.taxonomy_id, Number(row.count || 0));\n\t\t\t}\n\t\t}\n\t\treturn counts;\n\t}\n\n\tprivate rowToTaxonomy(row: Selectable<TaxonomyTable>): Taxonomy {\n\t\treturn {\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tslug: row.slug,\n\t\t\tlabel: row.label,\n\t\t\tparentId: row.parent_id,\n\t\t\tdata: row.data ? JSON.parse(row.data) : null,\n\t\t\tlocale: row.locale,\n\t\t\ttranslationGroup: row.translation_group,\n\t\t\tsortOrder: row.sort_order,\n\t\t};\n\t}\n}\n"],"mappings":"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"}