MishkaCmsCore.Activity.Query (Mishka CMS Core v0.0.1)

Copy Markdown View Source

DB-side merge for the unified activity log. Builds one UNION ALL across every discovered AshPaperTrail version table, AshEvents event-log table, and the oban_jobs table, then lets Postgres do the filtering, ordering and pagination in a single round-trip — instead of fetching a wide window from each source and merging in memory.

Why it is shaped this way (so indexes are actually usable)

Every filter and the ORDER BY … LIMIT are pushed DOWN into each branch, and the branch caps itself at offset + limit rows. The global top-K can only come from each branch's own top-K, so merging the per-branch tops and re-sorting a tiny set is correct. This lets each branch use an index:

  • tenant scope is site_id = $1::uuid (raw uuid, NOT a text cast) so the (site_id, version_inserted_at) composite index is reachable;
  • ORDER BY version_inserted_at DESC LIMIT k rides that index (a bare (version_inserted_at) index serves the master path, where there is no site filter), instead of a Seq Scan + Sort over the whole table;
  • kind selects which branches run; severity is a branch-level predicate (and whole branches are skipped when they cannot match, e.g. version/event branches for severity = "error").

No COUNT(*) OVER (): the :load_more table never needs an exact total (gervaz derives more? from Ash's page_size+1 extra row), and an exact count forces Postgres to materialise every matching row — defeating the early-LIMIT. counts/1 is the deliberate exception — a separate, capped query for the page's KPI row, run off the render path, never joined onto page/1.

Discovery is dynamic (MishkaCmsCore.Activity.Discovery): the query adapts to whatever extensions are installed, with no hardcoded resource or table names. Per-source presentation (label, message, badge, icon) is applied in Elixir over only the page's rows via each source's entry_from_row/1.

See MishkaCmsCore.Activity.ActivityEntry (calls page/1 from its manual read) and the source adapters MishkaCmsCore.Activity.Sources.PaperTrail, MishkaCmsCore.Activity.Sources.EventLog, MishkaCmsCore.Activity.Sources.Oban.

Summary

Functions

Bulk-resolve actor user_ids to %{email, site_id} in ONE query — so the table's actor column doesn't Ash.get per row (an N+1 that also dragged in the user's permissions/site loads). id = ANY($1::text[]::uuid[]) keeps the param text (Postgrex-friendly) while comparing against the un-cast uuid pkey.

How many entries page/1 has to show, as %{total: n, info: n, warn: n, error: n} — the log's KPI row and its "showing 1–20 of N" label.

How many entries fall on each day, as %{~D[2026-07-16] => 15} — the "15 events" beside a day's heading in the timeline.

The most rows any one branch is scanned for — a count that reaches it means "or more".

Runs the unified activity query for one page and returns the entry maps (the MishkaCmsCore.Activity.ActivityEntry shape), already ordered newest-first.

Humanise a "Elixir.A.B.Thing" resource string to "Thing" (spaced).

Grouped options for the resource/source :select filter — one {extension_label, [{resource_label, module_string}]} group per extension (so same-named resources in different extensions, e.g. Blog vs Document "Tag", stay distinct). Covers version parent modules + distinct event-log resource values; the value is the module string the SQL filters on.

Whether a resource shares its global (site_id nil) rows with tenants — true iff it exposes the :for_tenant_with_globals read, the same rule its own admin page uses (Component, Layout, JsHook, Stylesheet). Tenants see global activity only for these; everything else (Page, ErrorPage, Media, blog, document, …) stays own-site-only for tenants.

Bulk-resolve site ids to their name in ONE query (archived included).

How many entries match opts in total — what page/1 is showing a window of.

Functions

actor_emails(user_ids, viewer \\ nil)

Bulk-resolve actor user_ids to %{email, site_id} in ONE query — so the table's actor column doesn't Ash.get per row (an N+1 that also dragged in the user's permissions/site loads). id = ANY($1::text[]::uuid[]) keeps the param text (Postgrex-friendly) while comparing against the un-cast uuid pkey.

Scoped to the viewer: a site user loads only its own site's users, so another site's or a master's email never reaches the request. A master (a user whose site_id is nil) loads any actor. No viewer, or anything that is not a user, loads nothing. MishkaCmsCore.Activity.Helpers.actor_labels/2 names the rest.

counts(opts \\ %{})

@spec counts(map()) :: %{
  total: non_neg_integer(),
  info: non_neg_integer(),
  warn: non_neg_integer(),
  error: non_neg_integer()
}

How many entries page/1 has to show, as %{total: n, info: n, warn: n, error: n} — the log's KPI row and its "showing 1–20 of N" label.

Takes the same opts as page/1 minus :severity, :limit and :offset: each figure is a COUNT over the very same UNION, so a count always agrees with the list beside it. Like page/1, every branch is capped at @max_cap, so a log past that reports the cap rather than scanning forever.

day_counts(opts \\ %{})

@spec day_counts(map()) :: %{required(Date.t()) => non_neg_integer()}

How many entries fall on each day, as %{~D[2026-07-16] => 15} — the "15 events" beside a day's heading in the timeline.

Takes the same opts as counts/1. One GROUP BY over the same UNION, so the figure is the day's REAL total rather than however much of that day happens to be loaded — the log pages with load_more, and a heading that counted only the loaded rows would keep changing as you scroll.

max_cap()

@spec max_cap() :: pos_integer()

The most rows any one branch is scanned for — a count that reaches it means "or more".

page(opts)

Runs the unified activity query for one page and returns the entry maps (the MishkaCmsCore.Activity.ActivityEntry shape), already ordered newest-first.

Opts:

  • :site_id — the one site to read; nil reads every site, which only a master's reach asks for (MishkaCmsCore.Activity.Helpers.reach/1)
  • :kind — "content" | "job" | nil

  • :severity — "info" | "warn" | "error" | nil

  • :resources — :all (the default) or the "Elixir.Module" strings whose entries are shown, as MishkaCmsCore.Activity.Helpers.resources_for/1 answers for a viewer
  • :limit — page size (Ash sets this to page_size + 1 so more? works)
  • :offset — page offset

resource_label_of(resource_string)

Humanise a "Elixir.A.B.Thing" resource string to "Thing" (spaced).

resource_options()

Grouped options for the resource/source :select filter — one {extension_label, [{resource_label, module_string}]} group per extension (so same-named resources in different extensions, e.g. Blog vs Document "Tag", stay distinct). Covers version parent modules + distinct event-log resource values; the value is the module string the SQL filters on.

shares_globals?(resource)

Whether a resource shares its global (site_id nil) rows with tenants — true iff it exposes the :for_tenant_with_globals read, the same rule its own admin page uses (Component, Layout, JsHook, Stylesheet). Tenants see global activity only for these; everything else (Page, ErrorPage, Media, blog, document, …) stays own-site-only for tenants.

site_names(site_ids)

Bulk-resolve site ids to their name in ONE query (archived included).

total(opts \\ %{})

@spec total(map()) :: non_neg_integer()

How many entries match opts in total — what page/1 is showing a window of.

Same opts as page/1 minus :limit/:offset. The read hands this to Ash as full_count so the table can say how far into the log a page reaches; Ash.DataLayer.Simple cannot count a manual read by itself. Capped like page/1, so a log past @max_cap reports the cap.