# `MishkaCmsCore.Runtime.Handlers.UiKitUpgrade`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/handlers/ui_kit_upgrade.ex#L5)

What a site's kit components are against the bundle on disk now, and the one write that brings
them forward without taking an admin's edits with it.

## The gap this closes

`MishkaCmsCore.Runtime.Handlers.UiKitHandler.install_kit!/2` upserts by name, so it has exactly two
settings: install every component the bundle ships — over the top of whatever an admin edited in
the builder — or install only the ones a site has no row for at all, which is what every
provisioner does and what leaves a site on the components it was seeded with for ever. A site set
up in September kept September's `blog-post-list` while the bundle grew a `language` filter, and
nothing anywhere compared the two.

Nothing could compare them, because a row said only which kit had written it. It now says which
VERSION of that kit wrote it and carries a fingerprint of what was written, both stamped by the
installer into `extra` — see `stamp/3` — so three questions can be answered per row without
guessing: is this even a row this kit wrote, has the bundle moved since it was written, and has
anybody here changed it since.

The second question is not the version string's to answer alone. A kit is edited far more often
than it is renumbered, so the row's fingerprint is weighed against the BUNDLE's as well as against
the one its own stamp carries: the first says whether the bundle has moved, the second whether
anybody here has.

## The three answers, and the fourth

`plan/2` sorts every component the bundle ships into:

  * `:current` — the bundle has nothing this row does not already hold, or the only thing it does
    not hold is the admin's own work;
  * `:outdated` — the bundle's copy has moved on, by its version or by its content, and the row is
    still byte-for-byte what the kit installed, so an update takes nothing away. This is the group
    the safe button writes;
  * `:edited` / `:unknown` — the "needs a choice" pile. Either the row no longer matches the
    fingerprint it was installed with, or it carries no fingerprint to compare (a row installed
    before any of this, or by another kit). Both are offered together and both are only written
    when the admin asks for them by name, because the second is the same risk as the first: the
    code cannot tell an edit from the original, so it says so rather than picking for them;
  * `:archived` and `:absent`, which are never written by an upgrade at all. An archived row is an
    answer the admin gave, and a component the site has no row for is what the extension's
    "Set up …" button is for — an upgrade that installed those would hand a site components it
    deliberately does not have.

Updating a row in the third pile is not destructive either: `master_update` is what the installer
writes through, so AshPaperTrail keeps what the row held in `component_versions` and the admin can
restore it from the component's history. `message/1` says so in the same sentence that reports it.

## Every kit is stamped; not every kit has a button

`stamp/3` runs inside the installer, so Chelekom's rows carry the same three keys as an
extension's and `plan/2` reads them with no special case. What Chelekom does not have here is a
button, and the reason is `install_kit!/2`'s `:selection`: its wizard installs a NARROWED kit —
chosen components, chosen variants and colours — and this module writes the bundle's component
whole. For an extension bundle those are the same thing, since nothing narrows them; for a site
that installed Chelekom's `base` variant only, an update would quietly hand it every variant.
Wiring Chelekom up means recording the selection a site was installed with and replaying it, which
is a question no bundle answers today.

## Pressing it twice

The write goes through `install_kit!/2` with a selection naming only the rows that were chosen, so
a second press finds them all holding exactly what the bundle ships and writes nothing — it does
not even reach the installer, which would otherwise rebuild the site's runtime to discover there
was nothing to do.

# `entry`

```elixir
@type entry() :: %{
  name: String.t(),
  standing: standing(),
  version: String.t() | nil,
  kit: String.t() | nil,
  reason: atom() | nil
}
```

One component, its standing, and what the row says it was installed from.

# `plan`

```elixir
@type plan() :: %{
  kit: String.t(),
  version: String.t(),
  site_id: String.t() | nil,
  current: [entry()],
  outdated: [entry()],
  choice: [entry()],
  archived: [entry()],
  absent: [entry()]
}
```

# `report`

```elixir
@type report() :: %{
  kit: String.t(),
  version: String.t(),
  updated: [String.t()],
  added: [String.t()],
  edited: [String.t()],
  left: [String.t()],
  archived: [String.t()],
  absent: [String.t()],
  current: [String.t()]
}
```

# `standing`

```elixir
@type standing() :: :current | :outdated | :edited | :unknown | :archived | :absent
```

Where one component of the bundle stands on this site.

`:unknown` is the honest answer rather than a fourth guess: the row is installed and nothing
readable on it says what it was installed from, so it is offered with the edited ones.

# `digest`

```elixir
@spec digest(map() | struct()) :: String.t()
```

The fingerprint of what a component is made of, taken from a bundle entry or from a stored row.

Both shapes go through the same projection — the bundle's string keys and the row's atoms and
embedded structs land on the same canonical term — so a digest taken at install still matches the
row it was taken from, and stops matching the moment somebody edits it.

# `message`

```elixir
@spec message(report()) :: String.t()
```

What one upgrade did, worded from what was actually written.

The sentence about history is not decoration: updating a row an admin edited is only safe because
the row they had is kept as a version, and an admin who is not told that has no way to know they
can get it back.

# `note`

```elixir
@spec note(entry()) :: String.t()
```

The line a dashboard shows beside one component, saying what its standing is FROM.

A standing on its own reads as a verdict the admin has to take on trust; the version a row was
installed from, and the fact that an old row cannot be told from an edited one, are what let them
decide.

# `plan`

```elixir
@spec plan(String.t(), String.t() | nil, map() | nil) ::
  {:ok, plan()} | {:error, term()}
```

Where every component of the bundle at `bundle_path` stands on `site_id` (`nil` for global rows).

Reads nothing but the rows the bundle names, and writes nothing at all: this is what a dashboard
shows an admin BEFORE anything happens.

`{:error, :kit_has_no_version}` when the bundle does not say which version it is. Without one
nothing can be called outdated, and reporting every row as current would be a worse answer than
saying the kit cannot be compared.

# `stamp`

```elixir
@spec stamp(map(), String.t(), String.t() | nil) :: map()
```

Records on a component's params which kit wrote it, from which version, and what was written.

Called by the installer for every component of every bundle — there is no per-kit opt-in, because
a stamp only one kit writes is a stamp an upgrade can only read for that kit.

The fingerprint is taken from the params the install is ABOUT to write, which is what makes it
comparable afterwards: `digest/1` reads the same fields off the stored row.

# `upgrade!`

```elixir
@spec upgrade!(String.t(), String.t() | nil, keyword()) ::
  {:ok, report()} | {:error, term()}
```

Writes the newer components onto a site and says what it did.

`:include` picks which pile is written — `:safe` (the default) takes only the outdated rows
nobody has touched, `:all` takes those and the ones that need a choice. No other COMPONENT is
written: an archived row is skipped by the installer as well, and a component the site has no row
for is not offered here at any setting.

The selection reaches the installer's component list and nothing else, so a bundle's `js_hooks`
and `stylesheets` are still upserted whole — and a hook the bundle's `extra.replaces` names is
still deleted. Neither extension bundle ships either today; a kit that does would have an admin's
edit to a hook replaced here without it appearing in the report, because nothing stamps a hook.

Idempotent by construction. The plan is read again inside this call rather than taken from the
caller, so a row that was updated a moment ago by somebody else is already current and is not
written twice — and with nothing left to write the installer is not called at all.

## The pieces an updated component is made of come with it

The one exception to "an absent component is never installed here", and it is not a softening of
that rule but what the rule is for. A kit may SPLIT a component: the blog's post list stopped
drawing its own cards and started drawing `blog-post-card`, a component of the same bundle. A site
that took the update and not the card would be given a list whose every row is the runtime's red
"failed to render" box — the update would break the page it was meant to improve.

So a component this call writes brings the components its own row DECLARES it needs, and theirs in
turn, wherever the site has no row for them at all. They are reported separately, as `added`,
because they are not updates of anything the admin had. A piece the admin ARCHIVED is still left
where it is: that is an answer they gave about a component they have.

A component the site simply does not use and nothing it does use needs is still not installed.
That is what the extension's "Set up …" button is for.

Pass `actor: user` and every component written is written as them, through the action their own
role may use. Leave it out and the write is the system's, as it is for the wizard.

---

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