Where one kind of item goes in the CMS: one module that is a mishka_installer plugin on the
"migration:targets" event and names an existing CMS resource and the create and update actions
that write it. The extension adds no write path of its own: every record is written by that
action, as the admin who pressed Import and under the run's site, so the resource's policies,
validations and plugins decide as they do for anything else.
defmodule MyExtension.Targets.Post do
use MishkaCmsMigration.Target,
kind: "post",
resource: MishkaBlog.BlogPost,
create: :create,
update: :update
@impl true
def attrs(item, _context) do
%{
title: item.title,
slug: item.attrs["slug"],
content: MishkaCmsMigration.Source.Item.html(item)
}
end
end:create and :update default to the resource's :create and :update. use makes the module
a plugin on "migration:targets" — registering is the declaration; the event is never fired, and
the plugin's call/1 hands its state back — and adds id/0 (the kind), resource/0,
actions/0, a note/2 that says nothing, an existing/2 that finds nothing, a prepare/3
that writes at once, a waiting/2 that plans every write and a needs/1 that needs nothing; a
target writes only attrs/2, note/2 when it writes an item otherwise than the source has it,
existing/2 when the CMS may already hold some of its items, prepare/3 and waiting/2 when a
write needs something from the source first or may wait for an admin, and needs/1 when its
record points at items of other kinds.
A target registers once it is supervised. An extension that ships one lists the module among the
children of its own application's supervisor, as MishkaCmsMigration.Application.plugins/0 lists
this extension's; no list here names it. Which target writes a kind is read from
mishka_installer's registry (MishkaCmsMigration.Source.installed/2): a target an operator stops
in the installer writes nothing, and its kind is skipped as "no_target", or, for a kind the
Blog writes, waits for it ("blog_missing", MishkaCmsMigration.Application.blog_kinds/0).
What the resource needs
Checked when the target compiles, which fails with the reason when one is missing:
- it is an Ash resource whose primary key is
:id, a UUID — an item records the record it wrote by that id (MishkaCmsMigration.MigrationItem'starget_id); - its
updated_atis anupdate_timestamp, which Ash sets on every write and no input may set — a re-run compares it with the one recorded when the record was written, to keep an admin's edits (below); a plainupdated_atattribute, or one an action may be handed, is refused; :createnames one of its create actions, or a generic action that answers one of its records (returns :struct, constraints: [instance_of: <the resource>]), such as the core Media's:upload, which stores a file and creates its record;:updatenames one of its update actions, or a create action that upserts (upsert? true), such as the core Setting's:set, which writes over the record with the same identity and keeps its id.
The source's other fields
item.extra holds the fields of the source's row the CMS has no home for (a WordPress post's
custom fields, ACF fields, another plugin's meta); the item's record keeps them either way
(MishkaCmsMigration.MigrationItem's extra). A target whose resource keeps an extra map of
its own — the core's pages, layouts and components do — may copy them there, under a key of its
own so they sit beside what the CMS keeps in it:
def attrs(item, _context),
do: %{title: item.title, path: item.attrs["path"], extra: %{"migration" => item.extra}}An item's parent
An item that sits under another of its kind (MishkaCmsMigration.Source.Item's parent, such
as a category's parent category) is written after its parent, and attrs/2 is handed the id of
the CMS record the parent was written to as context.parent_id (nil for an item with none), to
put where the resource keeps its parent:
def attrs(item, context),
do: %{name: item.title, slug: item.attrs["slug"], parent_collection_id: context.parent_id}An item whose parent has no CMS record is not written; it is skipped with a reason that names
the parent and what unblocks it, such as "parent_undecided: Team (#25)"
(MishkaCmsMigration.Workers.Helpers.Writer). One whose parent an admin left out is skipped as
"parent_left_out", in a dry run too (MishkaCmsMigration.Workers.Helpers.Pipeline).
A create and an update
attrs/2 builds the input of both actions. On an update its context's record is the CMS
record being written over, and nil on a create, so a target can leave out what is set only
once, such as whose profile a blog author is. It runs inside the write's transaction, as the
admin: what it writes first for the record to point at (the Blog's author target makes the user
its profile names) is rolled back with the record when the write fails, and one it cannot write
raises, which fails the item with the error.
What a write needs first
A write that needs something from the source before it runs, such as a file to download, gets it
in prepare/3. It is handed the item, the context attrs/2 gets and write, the item's write and
its record in one transaction, and answers what write.(context) answers, after putting what it
fetched in the context's prepared for attrs/2 to read, or {:skipped, reason} or
{:error, reason} without writing: the item is then skipped or failed with reason. It may
answer {:connected, record, reason} too, for an item it found to be a record the CMS already
holds: nothing is written, and the item is recorded against record, as skipped with reason,
the way existing/2's {:connected, record} is. It may answer {:undecided, conflict} too, for a conflict only the write can tell, such as a player an
admin who may not allow it would write: the item waits for an admin's decision, as one
existing/2 answers so does. It runs in
an import only, after the item is converted and offered to the plugins, outside the write's
transaction, so a slow source holds no database transaction open, and it cleans up what it
fetched once write answers. use answers write.(context).
A dry run runs no prepare/3, so it asks waiting/2 instead, of each item it would create or
update, with the context prepare/3 would get: what the import's prepare/3 would answer
without writing, {:undecided, conflict} or {:skipped, reason}, or nil for a write. The dry
run then plans the item as the import would leave it: a post an earlier import wrote, whose
player the admin may not allow, is planned as waiting for a decision, not as an update. use
answers nil.
def waiting(item, context), do: MishkaCmsMigration.Targets.Helpers.Frames.waiting(item, context)
def prepare(item, %{record: nil} = context, write) do
path = Plug.Upload.random_file!("migration")
try do
with :ok <- fetch(item, path), do: write.(%{context | prepared: path})
after
File.rm(path)
end
end
def prepare(_item, context, write), do: write.(context)What the database can tell for a whole page at once, such as who wrote each of its comments, is
found in existing/2 instead of prepare/3: one read for the page, in a dry run and in an
import, handed to each item as its context's prepared ({:created, prepared}, see "What the
CMS already holds"), so neither note/2 nor the write asks again for each item.
Where an item is written
Every item of a kind goes to the target's resource/0 through its actions/0. A target whose
items go to different resources, such as a menu that an admin sends to the site layout or to a
component of its own (MishkaCmsMigration.Targets.CmsLayout), names them all in resources/0
and answers each item's in writes_to/2, handed the context with the item's decisions. The item
records the resource of the record it was written to, and a re-run reads it from there.
What the CMS already holds
An item may already be in the CMS without any run having written it, such as a WordPress author
who is a user of the site with a blog profile of their own. existing/2 is handed a page's items
no run has written yet, with the context attrs/2 gets (no parent and no record), before
anything is written and in a dry run too, and answers by source id:
{:connected, record}— the item is that CMS record. It is recorded against it and never written, by this run or a later one: the record is the CMS's own and is kept as it is, and the report says"connected"(MishkaCmsMigration.Workers.Helpers.Decision);{:updated, record}— the item is written over that CMS record through the update action, as though an earlier run had written it, and is that record from then on: what a target answers whose item lands in a record every site already has, such as the main menu, which becomes the links of the site layout's header (MishkaCmsMigration.Targets.CmsLayout). A later run treats the record as any other a run wrote;{:created, prepared}— the item is created, as one the answer leaves out is, and its context'spreparedispreparedfrom the start: what the target found for its write in its read of the page, such as a comment's author, fornote/2,waiting/2,prepare/3,attrs/2andrecorded/2to read. It is found from the item as the source lists it, before the plugins ofmigration:item:before_import(MishkaCmsMigration.Events) see it;{:skipped, reason}— the item is left out, withreason;{:undecided, conflict}— the item meets something the site already has that only an admin can settle, such as a setting the site has a value of its own for: nothing is written, the report saysconflict(MishkaCmsMigration.Report), and every later run asks again until an admin decides on the Items page.
An item the answer leaves out is created. It reads as the admin running the job, under the CMS's
own policies. use answers %{}.
def existing(items, context) do
held = held_by_slug(items, context)
for item <- items, record = held[item.attrs["slug"]], into: %{},
do: {item.source_id, {:connected, record}}
endAn admin's decision
An admin answers a conflict on the item's row (MishkaCmsMigration.MigrationItem's
decisions), and the answer holds for every later dry run and import. decision/3 reads it from
the context: "keep", the site stays as it is (its own value is kept, the item is connected to
the record it has, or it waits); "import", the item is written the import's way; nil while no
admin has answered. A target answers by it in existing/2, and in note/2, prepare/3 and
attrs/2, which are handed the same decisions:
def existing(items, context) do
for item <- items, own?(item, context), into: %{} do
{item.source_id, kept(Target.decision(context, item, "site_has_own"))}
end
end
defp kept("keep"), do: {:skipped, "site_own_kept"}
defp kept("import"), do: {:updated, record}
defp kept(nil), do: {:undecided, "site_has_own"}What an item needs
needs/1 names, by kind, the source ids of the items of other kinds whose CMS records the item's
record points at, such as a post's author, categories, tags and files:
%{"user" => ["3"], "category" => ["4", "5"]}. A full run walks those kinds first, so it does not
ask. A job of picked items (Dry run this item, Import this item) brings nothing it was not asked
for, so it asks: an item one of whose needs, or whose parent, has no CMS record yet (it was
never imported, failed or waits for an admin's decision) waits, skipped as its named parent
("parent_missing: <parent>" and the like), "post_missing" or "waits_for: <kinds>"
(MishkaCmsMigration.Report), in the dry run too (MishkaCmsMigration.Workers.Helpers.Needs).
One an admin left out, or an import skipped, does not hold it, as it would not in a full run.
use answers %{}.
def needs(item), do: %{"category" => Enum.map(item.attrs["categories"] || [], &to_string/1)}What the report says
note/2 is what the report says about an item the target writes otherwise than the source has
it, such as a post pending review that the CMS keeps as a draft: a short code the admin page puts
in words (MishkaCmsMigration.MigrationRun.reason_label/1), several joined by "; ", or nil.
It is handed the context attrs/2 gets, whose mode says whether the run is a :dry_run, which
writes nothing, or an :import; in a dry run no parent is looked up. It is the entry's reason in
a dry run and in an import, and the item's record keeps it, before the pieces of its content no
plugin converted (MishkaCmsMigration.Report).
def note(%{attrs: %{"status" => "pending"}}, _context), do: "draft_from_pending"
def note(_item, _context), do: nilKeeping an admin's edits
A re-run reads the records earlier runs wrote, as the admin running it
(MishkaCmsMigration.Workers.Helpers.Decision), and compares each one's updated_at with the
one the run recorded when it wrote it. A record changed in the CMS since is never written again,
and one that is gone is not brought back.
Items that share one record, such as the menus written into the site layout, each record the
updated_at of their own write. A target that writes such a record again records the new
updated_at for the other items of that record too (MishkaCmsMigration.record_item/2, with the
context's scope), so a write of one is not mistaken for an edit in the CMS.
Summary
Types
What attrs/2 is told: whether the run is a dry run or an import, who writes, on which site,
from which run of which source (its id, such as "wordpress", and host), with which choices, the
the job's scope, for a target that records other items it changed (see "Keeping an admin's
edits"), the CMS record of the item's parent (see "An item's parent"), the record an update writes over (see
"A create and an update"), what the write needs that existing/2 found ({:created, prepared})
or prepare/3 fetched (nil until one does), the language plugin the source runs
(languages, "polylang" or "wpml", else nil), and an
admin's decisions on the items, by source id (see "An admin's decision", read with
decision/3).
What existing/2 says of one item: the CMS record it already is, the one it is written over, what
its write needs when it is created, why it is left out, or the conflict it waits on for an
admin's decision.
Callbacks
The resource's actions this target writes through.
The input of the create or update action for item.
What the CMS already holds of items, a page of items no run has written yet, by source id; an
item left out is created (see "What the CMS already holds"). use answers %{}.
The kind of item this target writes, such as "post".
The items of other kinds item's record points at, their source ids by kind (see "What an item
needs"). use answers %{}.
What the report says about item when it is written otherwise than the source has it, or nil
(see "What the report says"). use answers nil.
Runs write, the import's write of item, with what it needs from the source first, or answers
{:skipped, reason}, {:undecided, conflict}, {:connected, record, reason} or
{:error, reason} without writing (see "What a write needs first"). use answers
write.(context).
What the target adds to the item's own record (MishkaCmsMigration.MigrationItem) when an import
writes it, from what prepare/3 fetched, such as %{file_hash: hash} for a downloaded file (see
"What a write needs first"), or %{content_hash: nil} for a write the next run makes again, such
as a stylesheet written without the fonts Google did not answer. use answers %{}.
The CMS resource the records are written to.
Every resource this target writes, for a target whose items go to more than one (see "Where an
item is written"). use answers [resource()].
What a dry run plans for item, one it would create or update, that the import's prepare/3
would not write: {:undecided, conflict}, {:skipped, reason}, or nil for a write (see "What
a write needs first"). use answers nil.
The resource and the actions that write item (see "Where an item is written"). use answers
{resource(), actions()}.
Functions
What an admin decided on item's conflict (see "An admin's decision"): "keep", "import",
or nil while no admin has answered it, and for a context that carries no decisions. A conflict
a later run found on an item the admin already decided takes that decision when it still applies
(MishkaCmsMigration.MigrationItem.carried/2), so the item is not asked again.
The event a target plugin registers on, the one use MishkaCmsMigration.Target names: what an
extension's own tests register a target for.
The installed target that writes kind.
Types
@type context() :: %{ mode: :dry_run | :import, actor: map(), scope: map(), site_id: String.t(), run_id: String.t(), source: String.t(), source_host: String.t(), options: map(), parent_id: Ecto.UUID.t() | nil, record: struct() | nil, prepared: term(), languages: String.t() | nil, decisions: %{required(String.t()) => %{required(String.t()) => String.t()}} }
What attrs/2 is told: whether the run is a dry run or an import, who writes, on which site,
from which run of which source (its id, such as "wordpress", and host), with which choices, the
the job's scope, for a target that records other items it changed (see "Keeping an admin's
edits"), the CMS record of the item's parent (see "An item's parent"), the record an update writes over (see
"A create and an update"), what the write needs that existing/2 found ({:created, prepared})
or prepare/3 fetched (nil until one does), the language plugin the source runs
(languages, "polylang" or "wpml", else nil), and an
admin's decisions on the items, by source id (see "An admin's decision", read with
decision/3).
@type found() :: {:connected, struct()} | {:updated, struct()} | {:created, term()} | {:skipped, String.t()} | {:undecided, String.t()}
What existing/2 says of one item: the CMS record it already is, the one it is written over, what
its write needs when it is created, why it is left out, or the conflict it waits on for an
admin's decision.
Callbacks
The resource's actions this target writes through.
@callback attrs(MishkaCmsMigration.Source.Item.t(), context()) :: map()
The input of the create or update action for item.
@callback existing([MishkaCmsMigration.Source.Item.t()], context()) :: %{ required(String.t()) => found() }
What the CMS already holds of items, a page of items no run has written yet, by source id; an
item left out is created (see "What the CMS already holds"). use answers %{}.
@callback id() :: String.t()
The kind of item this target writes, such as "post".
@callback needs(MishkaCmsMigration.Source.Item.t()) :: %{ required(String.t()) => [String.t()] }
The items of other kinds item's record points at, their source ids by kind (see "What an item
needs"). use answers %{}.
@callback note(MishkaCmsMigration.Source.Item.t(), context()) :: String.t() | nil
What the report says about item when it is written otherwise than the source has it, or nil
(see "What the report says"). use answers nil.
@callback prepare(MishkaCmsMigration.Source.Item.t(), context(), (context() -> result)) :: result | {:skipped, String.t()} | {:undecided, String.t()} | {:connected, struct(), String.t()} | {:error, term()} when result: term()
Runs write, the import's write of item, with what it needs from the source first, or answers
{:skipped, reason}, {:undecided, conflict}, {:connected, record, reason} or
{:error, reason} without writing (see "What a write needs first"). use answers
write.(context).
@callback recorded(MishkaCmsMigration.Source.Item.t(), context()) :: map()
What the target adds to the item's own record (MishkaCmsMigration.MigrationItem) when an import
writes it, from what prepare/3 fetched, such as %{file_hash: hash} for a downloaded file (see
"What a write needs first"), or %{content_hash: nil} for a write the next run makes again, such
as a stylesheet written without the fonts Google did not answer. use answers %{}.
@callback resource() :: Ash.Resource.t()
The CMS resource the records are written to.
@callback resources() :: [Ash.Resource.t()]
Every resource this target writes, for a target whose items go to more than one (see "Where an
item is written"). use answers [resource()].
@callback waiting(MishkaCmsMigration.Source.Item.t(), context()) :: {:undecided, String.t()} | {:skipped, String.t()} | nil
What a dry run plans for item, one it would create or update, that the import's prepare/3
would not write: {:undecided, conflict}, {:skipped, reason}, or nil for a write (see "What
a write needs first"). use answers nil.
@callback writes_to(MishkaCmsMigration.Source.Item.t(), context()) :: {Ash.Resource.t(), %{create: atom(), update: atom()}}
The resource and the actions that write item (see "Where an item is written"). use answers
{resource(), actions()}.
Functions
@spec decision(map(), MishkaCmsMigration.Source.Item.t(), String.t()) :: String.t() | nil
What an admin decided on item's conflict (see "An admin's decision"): "keep", "import",
or nil while no admin has answered it, and for a context that carries no decisions. A conflict
a later run found on an item the admin already decided takes that decision when it still applies
(MishkaCmsMigration.MigrationItem.carried/2), so the item is not asked again.
@spec event() :: String.t()
The event a target plugin registers on, the one use MishkaCmsMigration.Target names: what an
extension's own tests register a target for.
The installed target that writes kind.