MishkaCmsMigration.Target behaviour (Mishka CMS Migration v0.0.1)

Copy Markdown View Source

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's target_id);
  • its updated_at is an update_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 plain updated_at attribute, or one an action may be handed, is refused;
  • :create names 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; :update names 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's prepared is prepared from the start: what the target found for its write in its read of the page, such as a comment's author, for note/2, waiting/2, prepare/3, attrs/2 and recorded/2 to read. It is found from the item as the source lists it, before the plugins of migration:item:before_import (MishkaCmsMigration.Events) see it;
  • {:skipped, reason} — the item is left out, with reason;
  • {: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 says conflict (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}}
end

An 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: nil

Keeping 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

context()

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

found()

@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

actions()

@callback actions() :: %{create: atom(), update: atom()}

The resource's actions this target writes through.

attrs(t, context)

@callback attrs(MishkaCmsMigration.Source.Item.t(), context()) :: map()

The input of the create or update action for item.

existing(list, context)

@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 %{}.

id()

@callback id() :: String.t()

The kind of item this target writes, such as "post".

needs(t)

@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 %{}.

note(t, context)

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

prepare(t, context, function)

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

recorded(t, 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 %{}.

resource()

@callback resource() :: Ash.Resource.t()

The CMS resource the records are written to.

resources()

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

waiting(t, context)

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

writes_to(t, context)

@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

decision(context, item, conflict)

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

event()

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

fetch(kind)

@spec fetch(String.t() | nil) :: {:ok, module()} | {:error, :not_installed}

The installed target that writes kind.