PhoenixKit.Modules.Publishing.DBStorage (PhoenixKitPublishing v0.7.0)

Copy Markdown View Source

Database storage layer for the Publishing module.

Provides CRUD operations for publishing groups, posts, versions, and contents via PostgreSQL with Ecto.

Summary

Functions

Every group slug, regardless of status — the set a new slug has to avoid.

Clears a post's active_version_uuid ONLY when it still equals expected_uuid — the compare-and-swap StaleFixer's pointer heal needs. A plain write raced concurrent publishes: the fixer judged the pointer stale from an earlier read, a publish moved it to a fresh version in between, and the unconditional clear reverted the publish. Returns the number of rows updated (0 = the pointer moved; do nothing).

Clears a specific url_slug from all content rows of a post. Returns cleared language codes.

Counts non-trashed posts in a group.

Creates content for a version/language.

Creates a publishing group.

Creates a post within a group.

Creates a new version for a post.

Creates a new version by cloning content from a source version.

Deletes content.

Deletes a group and all its posts (cascade).

Hard-deletes a post and all its versions/contents (cascade).

Demotes a published version to draft ONLY while its post's active_version_uuid is still NULL — the orphan-demotion StaleFixer runs from a possibly-stale snapshot, and an unconditional demote could draft a version a concurrent publish just made live. One atomic statement; returns the number of rows updated.

Finds content by a previous URL slug (stored in the data.previous_url_slugs JSONB array). Used to issue public 301 redirects from a post's old URL to its current one.

Public URL-slug lookup. Returns at most one published content row for the given group + language + slug, or nil. Excludes trashed posts and unpublished drafts (drafts are never reachable from a public URL — use find_by_url_slug_any_version/3 for the admin/self-healing path).

Internal URL-slug lookup that DOES surface unpublished drafts. Used by the stale-language self-healing flow (StaleFixer) and slug-uniqueness checks (SlugHelpers.url_slug_exists?) that need to see every existing slug, including those on posts that haven't been published yet.

Finds a post by date and time (timestamp mode, matches hour:minute only).

Gets the active (published) version for a post via active_version_uuid.

Gets content for a specific version and language.

Gets a group by UUID.

Gets a group by slug.

The same fetch, but only if the post really belongs to group_slug.

Gets the latest version for a post.

Gets a post by group slug and post slug. Excludes trashed posts.

Gets a timestamp-mode post by date and time.

Gets a post by UUID with preloads.

Gets a specific version by post and version number.

Fetches a version by its uuid.

Lists all content rows for a version.

Lists groups ordered by position. Filters by status (default: active only).

Lists available languages for a version.

Lists posts in a group, optionally filtered by status. Excludes trashed by default.

Lists all posts in a group in listing format (excerpt only, no full content).

Lists posts in slug mode (ordered by slug asc).

Lists posts in timestamp mode (ordered by date/time desc).

Lists all posts in a group with their latest version metadata.

Lists all versions for a post, ordered by version number.

Row-locks a group (FOR UPDATE) inside the caller's transaction and returns the fresh row — the group-save equivalent of lock_post_row!/2.

Row-locks a post (FOR UPDATE) inside the caller's transaction — the same lock Versions.publish_version/unpublish/delete take, so any writer that acquires it serializes with the publish machinery. Returns the fresh post row (or nil).

Gets the next version number for a post.

Returns true if url_slug is a PREVIOUS slug of another published post in the group+language (i.e. a slug that post's 301 redirect still owns). A new post claiming it would shadow that redirect — current-slug lookup wins over previous-slug lookup — so old URLs would land on the new post instead. M13.

Reads a full post with its latest version and content for a specific language.

Reads a timestamp-mode post by date and time instead of slug.

Resolves content for a language from a list of content rows.

Restores a trashed group by setting status to 'active'.

Restores a trashed post by clearing trashed_at.

Post uuids in a group whose ACTIVE PUBLISHED version has content matching query — a case-insensitive substring over per-language title + body — in any of the candidate languages. ILIKE wildcards in the user's input are escaped, so a search for "50%" matches the literal text. Capped at limit.

Streams every post in a group (including trashed) for batch operations that shouldn't materialise the whole listing in memory.

Returns true if the (group, date, minute) timestamp slot is occupied by ANY post — including trashed ones.

Trashes a group by setting status to 'trashed'.

Trashes a post by setting trashed_at timestamp.

Bulk-updates the status of all content rows for a version.

Bulk-updates the status of all content rows for a version, excluding a specific language.

Updates a publishing group.

Updates a post.

Updates a version.

Upserts content by version_id + language using ON CONFLICT.

Upserts a group by slug atomically via PostgreSQL ON CONFLICT.

Returns true when a custom url_slug is already taken in this group+language by a post OTHER than exclude_post_slug (any version, incl. drafts).

Functions

all_group_slugs()

@spec all_group_slugs() :: [String.t()]

Every group slug, regardless of status — the set a new slug has to avoid.

idx_publishing_groups_slug is a plain UNIQUE index with no status predicate, so a TRASHED group still owns its slug. A uniqueness probe built from list_groups/1 (active only) therefore handed create_group/1 a slug the insert could not write, and the constraint error surfaced as a bare "already exists" against a group the admin can no longer see.

clear_active_version_if(post_uuid, expected_uuid)

@spec clear_active_version_if(String.t(), String.t()) :: non_neg_integer()

Clears a post's active_version_uuid ONLY when it still equals expected_uuid — the compare-and-swap StaleFixer's pointer heal needs. A plain write raced concurrent publishes: the fixer judged the pointer stale from an earlier read, a publish moved it to a fresh version in between, and the unconditional clear reverted the publish. Returns the number of rows updated (0 = the pointer moved; do nothing).

clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear)

@spec clear_url_slug_from_post(String.t(), String.t(), String.t()) :: [String.t()]

Clears a specific url_slug from all content rows of a post. Returns cleared language codes.

count_posts(group_slug)

@spec count_posts(String.t()) :: non_neg_integer()

Counts non-trashed posts in a group.

create_content(attrs)

@spec create_content(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingContent.t())

Creates content for a version/language.

create_group(attrs)

@spec create_group(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingGroup.t())

Creates a publishing group.

create_post(attrs)

@spec create_post(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingPost.t())

Creates a post within a group.

create_version(attrs)

@spec create_version(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingVersion.t())

Creates a new version for a post.

create_version_from(post_uuid, source_version_number, opts \\ %{})

@spec create_version_from(String.t(), pos_integer(), map() | keyword()) ::
  {:ok, PhoenixKit.Modules.Publishing.PublishingVersion.t()} | {:error, term()}

Creates a new version by cloning content from a source version.

Creates a new version row and copies all content rows from the source. Also copies version-level data (featured_image, tags, seo, etc.). Wrapped in a transaction for atomicity.

Returns {:ok, %PublishingVersion{}} or {:error, reason}.

delete_content(content)

Deletes content.

delete_group(group)

Deletes a group and all its posts (cascade).

delete_post(post)

Hard-deletes a post and all its versions/contents (cascade).

demote_version_if_orphaned(version_uuid, post_uuid)

@spec demote_version_if_orphaned(String.t(), String.t()) :: non_neg_integer()

Demotes a published version to draft ONLY while its post's active_version_uuid is still NULL — the orphan-demotion StaleFixer runs from a possibly-stale snapshot, and an unconditional demote could draft a version a concurrent publish just made live. One atomic statement; returns the number of rows updated.

find_by_previous_url_slug(group_slug, language, url_slug)

@spec find_by_previous_url_slug(String.t(), String.t(), String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingContent.t() | nil

Finds content by a previous URL slug (stored in the data.previous_url_slugs JSONB array). Used to issue public 301 redirects from a post's old URL to its current one.

Published-only: matches the post's ACTIVE version exclusively. An unpublished post (no active_version_uuid, or a draft version) is not reachable from a public URL, so redirecting to it would just land the visitor on a 404 — the lookup must not surface those rows. Excludes trashed posts.

find_by_url_slug(group_slug, language, url_slug)

Public URL-slug lookup. Returns at most one published content row for the given group + language + slug, or nil. Excludes trashed posts and unpublished drafts (drafts are never reachable from a public URL — use find_by_url_slug_any_version/3 for the admin/self-healing path).

Tie-break: when two DIFFERENT published posts in the same group happen to share the same custom url_slug (the DB has no unique index on content url_slug across posts; the per-post (group_uuid, slug) index only prevents post-slug collisions), the query is allowed to return multiple rows. The OLDEST (incumbent) post wins (order_by [asc: p.uuid], exploiting UUIDv7's monotonic timestamp encoding — see schema docs for PublishingPost) so the post that owned the slug first keeps its URL; every newer loser's url_slug is auto-renamed with a -2, -3, … suffix so the next request resolves cleanly without crashing on Ecto.MultipleResultsError. This is a best-effort self-healing safety net for collisions that get past the application-level uniqueness check in PhoenixKit.Modules.Publishing.SlugHelpers, not a transactional correctness mechanism — concurrent requests racing on the same collision could each see the un-renamed state; one will eventually win the rename, the rest log warnings. If you see this fire in production it's a signal the upstream uniqueness check was skipped or raced; investigate the create-time path.

find_by_url_slug_any_version(group_slug, language, url_slug)

@spec find_by_url_slug_any_version(String.t(), String.t(), String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingContent.t() | nil

Internal URL-slug lookup that DOES surface unpublished drafts. Used by the stale-language self-healing flow (StaleFixer) and slug-uniqueness checks (SlugHelpers.url_slug_exists?) that need to see every existing slug, including those on posts that haven't been published yet.

Returns at most one content row. When the slug matches multiple versions of the SAME post (the common case for posts that accumulated drafts), picks the active version when one exists, otherwise the latest draft by version_number. Does NOT auto-rename collisions — drafts may legitimately share slugs while still being authored.

find_post_by_date_time(group_slug, date, time)

@spec find_post_by_date_time(String.t(), Date.t(), Time.t() | nil) ::
  PhoenixKit.Modules.Publishing.PublishingPost.t() | nil

Finds a post by date and time (timestamp mode, matches hour:minute only).

get_active_version(post)

Gets the active (published) version for a post via active_version_uuid.

Reads from the preloaded :active_version association if present (see get_post/2), otherwise falls back to a direct lookup. This keeps callers that received a hand-built struct working while letting the read paths short-circuit the second round trip.

get_content(version_uuid, language)

Gets content for a specific version and language.

get_group(uuid)

Gets a group by UUID.

get_group_by_slug(slug)

@spec get_group_by_slug(String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingGroup.t() | nil

Gets a group by slug.

get_group_post_by_uuid(group_slug, uuid, preloads \\ [])

@spec get_group_post_by_uuid(String.t(), String.t(), list()) ::
  PhoenixKit.Modules.Publishing.PublishingPost.t() | nil

The same fetch, but only if the post really belongs to group_slug.

Every group-scoped mutation takes a group from the page the caller is on and a uuid from the event they sent, and those two are not checked against each other by get_post_by_uuid/2 — it looks up the uuid alone. A post uuid is not a secret (the public comment form renders one), so the pairing has to be verified rather than assumed: the group decides which cache to rebuild, which topic to broadcast on, and what the audit row says the actor touched. Answering for a post in another group gets all three wrong, and would be a straightforward hole the moment permissions become per-group rather than per-module.

get_latest_version(post_uuid)

@spec get_latest_version(String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingVersion.t() | nil

Gets the latest version for a post.

get_post(group_slug, post_slug)

Gets a post by group slug and post slug. Excludes trashed posts.

Preloads :active_version so that downstream get_active_version/1 calls read from the in-memory association instead of issuing a second query — the read-then-resolve hot path becomes a single round trip.

get_post_by_datetime(group_slug, date, time)

@spec get_post_by_datetime(String.t(), Date.t(), Time.t() | nil) ::
  PhoenixKit.Modules.Publishing.PublishingPost.t() | nil

Gets a timestamp-mode post by date and time.

Truncates seconds from the input time since URLs use HH:MM format only, and new posts are stored with seconds zeroed. For older posts with non-zero seconds, falls back to hour:minute matching.

get_post_by_uuid(uuid, preloads \\ [])

@spec get_post_by_uuid(String.t(), [atom() | tuple()]) ::
  PhoenixKit.Modules.Publishing.PublishingPost.t() | nil

Gets a post by UUID with preloads.

get_version(post_uuid, version_number)

Gets a specific version by post and version number.

get_version_by_uuid(version_uuid)

@spec get_version_by_uuid(String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingVersion.t() | nil

Fetches a version by its uuid.

list_contents(version_uuid)

Lists all content rows for a version.

list_groups(status \\ "active")

@spec list_groups(String.t() | nil) :: [
  PhoenixKit.Modules.Publishing.PublishingGroup.t()
]

Lists groups ordered by position. Filters by status (default: active only).

list_languages(version_uuid)

@spec list_languages(String.t()) :: [String.t()]

Lists available languages for a version.

list_posts(group_slug, status \\ nil)

@spec list_posts(String.t(), String.t() | nil) :: [
  PhoenixKit.Modules.Publishing.PublishingPost.t()
]

Lists posts in a group, optionally filtered by status. Excludes trashed by default.

list_posts_for_listing(group_slug)

@spec list_posts_for_listing(String.t()) :: [map()]

Lists all posts in a group in listing format (excerpt only, no full content).

Always uses Mapper.to_listing_map/4 which strips content bodies and includes only excerpts. Designed for caching in :persistent_term where data is copied to the reading process heap — keeping entries small matters.

list_posts_slug_mode(group_slug, status \\ nil)

@spec list_posts_slug_mode(String.t(), String.t() | nil) :: [
  PhoenixKit.Modules.Publishing.PublishingPost.t()
]

Lists posts in slug mode (ordered by slug asc).

list_posts_timestamp_mode(group_slug, status \\ nil, opts \\ [])

@spec list_posts_timestamp_mode(String.t(), String.t() | nil, keyword()) :: [
  PhoenixKit.Modules.Publishing.PublishingPost.t()
]

Lists posts in timestamp mode (ordered by date/time desc).

Options:

  • :date - Filter to a specific date (Date struct or ISO 8601 string)

list_posts_with_metadata(group_slug, status \\ nil)

@spec list_posts_with_metadata(String.t(), String.t() | nil) :: [map()]

Lists all posts in a group with their latest version metadata.

Returns a list of post maps suitable for listing pages.

list_versions(post_uuid)

Lists all versions for a post, ordered by version number.

lock_group_row!(repo, group_uuid)

@spec lock_group_row!(module(), String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingGroup.t() | nil

Row-locks a group (FOR UPDATE) inside the caller's transaction and returns the fresh row — the group-save equivalent of lock_post_row!/2.

lock_post_row!(repo, post_uuid)

@spec lock_post_row!(module(), String.t()) ::
  PhoenixKit.Modules.Publishing.PublishingPost.t() | nil

Row-locks a post (FOR UPDATE) inside the caller's transaction — the same lock Versions.publish_version/unpublish/delete take, so any writer that acquires it serializes with the publish machinery. Returns the fresh post row (or nil).

next_version_number(post_uuid)

@spec next_version_number(String.t()) :: pos_integer()

Gets the next version number for a post.

Uses SELECT ... FOR UPDATE to lock the row and prevent concurrent reads from getting the same number.

previous_url_slug_taken_by_other_post?(group_slug, language, url_slug, exclude_post_slug)

@spec previous_url_slug_taken_by_other_post?(
  String.t(),
  String.t(),
  String.t(),
  String.t() | nil
) :: boolean()

Returns true if url_slug is a PREVIOUS slug of another published post in the group+language (i.e. a slug that post's 301 redirect still owns). A new post claiming it would shadow that redirect — current-slug lookup wins over previous-slug lookup — so old URLs would land on the new post instead. M13.

read_post(group_slug, post_slug, language \\ nil, version_number \\ nil)

@spec read_post(String.t(), String.t(), String.t() | nil, pos_integer() | nil) ::
  {:ok, map()} | {:error, :not_found}

Reads a full post with its latest version and content for a specific language.

Returns a post map or nil if not found.

read_post_by_datetime(group_slug, date, time, language \\ nil, version_number \\ nil)

@spec read_post_by_datetime(
  String.t(),
  Date.t(),
  Time.t() | nil,
  String.t() | nil,
  pos_integer() | nil
) :: {:ok, map()} | {:error, :not_found}

Reads a timestamp-mode post by date and time instead of slug.

resolve_content(contents, language)

Resolves content for a language from a list of content rows.

Fallback chain: exact language match → site default language → first available.

restore_group(group)

Restores a trashed group by setting status to 'active'.

restore_post(post)

Restores a trashed post by clearing trashed_at.

search_published_post_uuids(group_slug, language_candidates, query, limit)

@spec search_published_post_uuids(String.t(), [String.t()], String.t(), pos_integer()) ::
  [String.t()]

Post uuids in a group whose ACTIVE PUBLISHED version has content matching query — a case-insensitive substring over per-language title + body — in any of the candidate languages. ILIKE wildcards in the user's input are escaped, so a search for "50%" matches the literal text. Capped at limit.

Returns bare uuids (not post maps) by design: the public search path filters the listing-cache's chronological maps by this set, reusing all the title/URL/language resolution the listing already does.

stream_posts(group_slug)

@spec stream_posts(String.t()) :: Enumerable.t()

Streams every post in a group (including trashed) for batch operations that shouldn't materialise the whole listing in memory.

Caller MUST be inside a Repo.checkout/1 (or an explicit transaction) — Postgres-backed Ecto streams require a checked-out connection. Yields raw %PublishingPost{} structs with :group preloaded; no version/content metadata (callers re-read what they need).

timestamp_slot_taken?(group_slug, date, time)

@spec timestamp_slot_taken?(String.t(), Date.t(), Time.t()) :: boolean()

Returns true if the (group, date, minute) timestamp slot is occupied by ANY post — including trashed ones.

get_post_by_datetime/3 filters out trashed posts (correct for serving), but the unique index on (group_uuid, post_date, post_time) includes them (to protect restore). So availability probes must see trashed rows too, otherwise a trashed post's slot looks free, the insert hits the index, and the collision retry can never resolve it.

trash_group(group)

Trashes a group by setting status to 'trashed'.

trash_post(post)

Trashes a post by setting trashed_at timestamp.

update_content(content, attrs)

Updates content.

update_content_status(version_uuid, new_status)

@spec update_content_status(String.t(), String.t()) :: {non_neg_integer(), nil}

Bulk-updates the status of all content rows for a version.

update_content_status_except(version_uuid, exclude_language, new_status)

@spec update_content_status_except(String.t(), String.t(), String.t()) ::
  {non_neg_integer(), nil}

Bulk-updates the status of all content rows for a version, excluding a specific language.

update_group(group, attrs)

Updates a publishing group.

update_post(post, attrs)

Updates a post.

update_version(version, attrs)

Updates a version.

upsert_content(attrs)

@spec upsert_content(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingContent.t())

Upserts content by version_id + language using ON CONFLICT.

upsert_group(attrs)

@spec upsert_group(map()) ::
  changeset_or_struct(PhoenixKit.Modules.Publishing.PublishingGroup.t())

Upserts a group by slug atomically via PostgreSQL ON CONFLICT.

The previous check-then-act version (get_group_by_slug then create or update) had a TOCTOU race: two concurrent callers with the same slug could both observe nil and both attempt to insert, with one crashing on the unique index. This version delegates conflict resolution to PostgreSQL and replaces the mutable columns on hit.

url_slug_taken_by_other_post?(group_slug, language, url_slug, exclude_post_slug)

@spec url_slug_taken_by_other_post?(
  String.t(),
  String.t(),
  String.t(),
  String.t() | nil
) :: boolean()

Returns true when a custom url_slug is already taken in this group+language by a post OTHER than exclude_post_slug (any version, incl. drafts).

Used by uniqueness checks: unlike fetching a single row and inspecting it, the exclusion happens in SQL, so a collision can't be masked when the arbitrarily-ordered match happens to be the post being edited.