Hierarchical, per-group categories (WordPress-parity taxonomy) and the assignments of posts to them. The category rows come from core migration V159.
Assignments are version-level
A post's filing lives in version.data["category_uuids"], next to the
version's tags, excerpt and SEO fields — not in a post-level join table.
A post's subject genuinely changes as it is revised (a release note that
grows into a guide), and a reader on ?v=2 should see how v2 was filed,
not how the live version is filed today. A new version inherits the whole
data map from the one it was created from, so the filing carries forward
by default and only differs where somebody changed it.
This is why there is no foreign key behind an assignment: delete_category/2
has to unfile the category by hand (unfile_deleted_category/1).
The V159 join table predates this and is drained on first use by
backfill_version_categories/1; nothing writes it any more.
Post-level helpers here (replace_post_categories/3,
categories_of_post/1, category_uuids_for_post/1) mean "the live
version, or the newest draft if it has never been published". Callers
that hold a specific version should read category_uuids off the post map
and resolve it with categories_by_uuids/2.
Context-layer rules the DB doesn't enforce
- Same-group parents — a category's parent must belong to the same group.
- No cycles — a category cannot become its own descendant; checked inside the update transaction so concurrent re-parents can't sneak a loop through.
- Assignment scope — only categories of the post's own group are accepted; foreign uuids are dropped rather than raising.
Summary
Functions
Moves the legacy post-level assignments onto the versions, once per group.
A group's category by slug.
Full category structs for a set of uuids within a group, position-ordered.
Category uuids per post, from each post's ACTIVE version —
%{post_uuid => [category_uuid]}.
Full category structs on a post's ACTIVE version, position-ordered.
Category uuids on a post's ACTIVE version.
Creates a category in a group. attrs may carry "name", "slug"
(auto-derived from the name when blank), "parent_uuid", "description",
"position", "name_i18n".
Deletes a category. The DB lifts its children to the root
(ON DELETE SET NULL) and cascades the post assignments.
A category by uuid.
Flat category list for a group, ordered by position then name. Degrades to
[] when the backing table is missing (host core < V159) — every public
read path must survive the release-gated window.
All categories of a group, tree-ordered: roots by position/name, each
followed by its (recursively ordered) children. Returns
%PublishingCategory{} structs with a virtual-ish :depth in the
returned tuples: [{category, depth}].
Re-parents a category, appending it at the END of the new sibling group
(the catalogue move_folder convention) — keeping its old position would
drop it mid-group unpredictably. Cycle/scope rules are
update_category/3's.
Published-post counts per category of a group (for the admin tree and
archive headers): %{category_uuid => count}. Counts posts with an
active published version, not trashed.
Persists a drag-reorder. ordered_uuids is the client's DOM order of
the whole flattened tree; rows are grouped by their EXISTING parent and
renumbered within each sibling group — a sortable drop can only reorder
siblings, never reparent (reparenting is update_category/3, which
cycle-checks). Unknown/foreign uuids are ignored rather than erroring.
Capped at 500 rows (client-misbehavior guard, same convention as the
assignment cap). Returns {:ok, changed_count}.
Files a post's ACTIVE version under category_uuids (max 100 — a
client-misbehavior guard, same convention as the reorder fns). Only
categories belonging to the post's group are accepted; unknown/foreign
uuids are silently dropped rather than erroring, so a stale selection
can't fail the whole save.
The uuids of a category and all its descendants within a group — the WordPress archive rule (a parent category's archive includes posts filed under its children). One query for the group's categories, then an in-memory walk; cycle-safe via the seen set.
Updates a category. Re-parenting is cycle-checked inside a transaction — the new parent must be same-group and not the category itself or any of its descendants.
Functions
Moves the legacy post-level assignments onto the versions, once per group.
Categories used to be a post-level join table, so every existing post's
filing lives there and nowhere else. Versions are the source of truth now,
and a version whose data has no category_uuids key is indistinguishable
from one deliberately filed under nothing — so without this, upgrading
silently unfiles the entire archive.
Copied onto EVERY version of the post, not just the active one, because that is what the old model meant: the assignment applied to whatever version you were looking at. Versions that already carry the key are left alone, so a real edit can never be overwritten.
The legacy rows are then dropped, which is what makes this self-limiting: no rows means nothing to move, so the steady state is a single cheap query rather than a scan that repeats forever. It also leaves the core-owned table empty, so whoever eventually drops it isn't deleting live data.
Returns the number of versions written.
A group's category by slug.
Full category structs for a set of uuids within a group, position-ordered.
The lookup categories now go through: assignments live on the version, so a reader has uuids and needs rows. Foreign or deleted uuids simply don't come back — a version filed under a category somebody later deleted renders with one fewer chip instead of erroring.
Category uuids per post, from each post's ACTIVE version —
%{post_uuid => [category_uuid]}.
The listing cache no longer needs this (it has the version in hand while building each entry), but bulk callers that only hold post uuids still do.
Full category structs on a post's ACTIVE version, position-ordered.
For a specific version, read category_uuids off the post map you already
have and resolve with categories_by_uuids/2 — the public post page does
that, so ?v=2 shows how v2 was filed.
Category uuids on a post's ACTIVE version.
Creates a category in a group. attrs may carry "name", "slug"
(auto-derived from the name when blank), "parent_uuid", "description",
"position", "name_i18n".
Deletes a category. The DB lifts its children to the root
(ON DELETE SET NULL) and cascades the post assignments.
A category by uuid.
Flat category list for a group, ordered by position then name. Degrades to
[] when the backing table is missing (host core < V159) — every public
read path must survive the release-gated window.
All categories of a group, tree-ordered: roots by position/name, each
followed by its (recursively ordered) children. Returns
%PublishingCategory{} structs with a virtual-ish :depth in the
returned tuples: [{category, depth}].
Re-parents a category, appending it at the END of the new sibling group
(the catalogue move_folder convention) — keeping its old position would
drop it mid-group unpredictably. Cycle/scope rules are
update_category/3's.
Published-post counts per category of a group (for the admin tree and
archive headers): %{category_uuid => count}. Counts posts with an
active published version, not trashed.
Persists a drag-reorder. ordered_uuids is the client's DOM order of
the whole flattened tree; rows are grouped by their EXISTING parent and
renumbered within each sibling group — a sortable drop can only reorder
siblings, never reparent (reparenting is update_category/3, which
cycle-checks). Unknown/foreign uuids are ignored rather than erroring.
Capped at 500 rows (client-misbehavior guard, same convention as the
assignment cap). Returns {:ok, changed_count}.
Files a post's ACTIVE version under category_uuids (max 100 — a
client-misbehavior guard, same convention as the reorder fns). Only
categories belonging to the post's group are accepted; unknown/foreign
uuids are silently dropped rather than erroring, so a stale selection
can't fail the whole save.
Assignments are version-level now, and this writes the version the public sees. The editor doesn't use it — a writer edits the version they have open, through the form — but it stays as the way to file a post when you have a post uuid and mean "the live one": seeds, imports, bulk tools.
The uuids of a category and all its descendants within a group — the WordPress archive rule (a parent category's archive includes posts filed under its children). One query for the group's categories, then an in-memory walk; cycle-safe via the seen set.
Updates a category. Re-parenting is cycle-checked inside a transaction — the new parent must be same-group and not the category itself or any of its descendants.