# `MishkaCmsCore.Activity.Query`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/activity/query.ex#L5)

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`.

# `actor_emails`

Bulk-resolve actor `user_id`s 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`

```elixir
@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`

```elixir
@spec day_counts(map()) :: %{required(Date.t()) =&gt; 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`

```elixir
@spec max_cap() :: pos_integer()
```

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

# `page`

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`

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?`

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`

Bulk-resolve site `id`s to their name in ONE query (archived included).

# `total`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
