MishkaCmsCore.Runtime.Handlers.UiKitUpgrade (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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.

Summary

Types

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

Where one component of the bundle stands on this site.

Functions

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

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

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

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

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

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

Types

entry()

@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()

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

report()

@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()

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

Functions

digest(source)

@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(report)

@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(map)

@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(bundle_path, site_id, actor \\ nil)

@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(params, kit_name, kit_version)

@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!(bundle_path, site_id, opts \\ [])

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