defmodule Mobus.Stepwise.Artifacts do
@moduledoc """
Canonical helpers for workflow-owned artifacts in the stepwise runtime.
Artifacts are durable, workflow-scoped data that should survive refreshes,
retries, and external callbacks (e.g. email confirmation links).
"""
@type artifact_entry :: %{
required(String.t()) => term()
}
@type artifact_map :: %{optional(String.t()) => artifact_entry()}
@doc """
Normalizes an artifact map into canonical format.
Each artifact entry is coerced to include `"kind"`, `"version"`, `"inserted_at"`,
and `"data"` keys with string keys throughout. Keys are stringified, and entries
without explicit metadata default to kind `"opaque"` and version `1`.
Returns an empty map for `nil` or non-map inputs.
## Parameters
* `artifacts` — a map of artifact entries, or `nil`
## Returns
* A normalized `artifact_map()` with string keys and canonical entry structure.
## Examples
Artifacts.normalize(%{report: %{kind: "pdf", data: "..."}})
#=> %{"report" => %{"kind" => "pdf", "version" => 1, "inserted_at" => "...", "data" => "..."}}
Artifacts.normalize(nil)
#=> %{}
"""
@spec normalize(map() | nil) :: artifact_map()
def normalize(nil), do: %{}
def normalize(artifacts) when is_map(artifacts) do
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
artifacts
|> Enum.reduce(%{}, fn {key, value}, acc ->
key = to_string(key)
if is_binary(key) do
Map.put(acc, key, normalize_entry(value, now))
else
acc
end
end)
end
def normalize(_), do: %{}
@doc """
Merges two artifact maps, with incoming entries overriding existing ones.
Both maps are normalized before merging. `nil` inputs are treated as empty maps.
## Parameters
* `existing` — the current artifact map (or `nil`)
* `incoming` — new artifacts to merge in (or `nil`)
## Returns
* A merged and normalized `artifact_map()`.
## Examples
Artifacts.merge(%{"a" => %{"data" => 1}}, %{"b" => %{"data" => 2}})
#=> %{"a" => %{...}, "b" => %{...}}
"""
@spec merge(map() | nil, map() | nil) :: artifact_map()
def merge(existing, incoming) do
existing = normalize(existing)
incoming = normalize(incoming)
Map.merge(existing, incoming)
end
defp normalize_entry(%{} = value, now) do
kind =
Map.get(value, "kind") ||
Map.get(value, :kind) ||
"opaque"
version =
Map.get(value, "version") ||
Map.get(value, :version) ||
1
inserted_at =
Map.get(value, "inserted_at") ||
Map.get(value, :inserted_at) ||
now
data =
Map.get(value, "data") ||
Map.get(value, :data) ||
value
entry = %{
"kind" => to_string(kind),
"version" => version,
"inserted_at" => inserted_at,
"data" => data
}
meta = Map.get(value, "meta") || Map.get(value, :meta)
if is_nil(meta), do: entry, else: Map.put(entry, "meta", meta)
end
defp normalize_entry(value, now) do
%{
"kind" => "opaque",
"version" => 1,
"inserted_at" => now,
"data" => value
}
end
end