defmodule Image.Plug.VariantStore.Persistence.File do
@moduledoc """
JSON-on-disk variant persistence backend.
Serialises variants as a single JSON file containing a top-level
array of variant objects:
[
{
"name": "thumbnail",
"options": "width=200,height=200,fit=cover,format=webp",
"metadata": {"description": "card thumbnail"},
"never_require_signed_urls": false,
"inserted_at": "2026-04-26T12:34:56Z",
"updated_at": "2026-04-26T12:34:56Z"
},
...
]
Whole-file rewrites on every `write/4` (atomic via
write-temp-then-rename). Suitable for stores up to a few thousand
variants; for larger or higher-write-rate workloads, plug a
database-backed implementation in instead.
### Configuration
variant_store: {Image.Plug.VariantStore.ETS, [
persistence: {
Image.Plug.VariantStore.Persistence.File,
path: "/var/lib/image_plug/variants.json",
provider: Image.Plug.Provider.Cloudflare # default
}
]}
### Options
* `:path` (required) — absolute filesystem path. The directory
must exist; the file is created if absent.
* `:provider` — module used to re-parse persisted options
strings at load time. Defaults to
`Image.Plug.Provider.Cloudflare`.
"""
@behaviour Image.Plug.VariantStore.Persistence
alias Image.Plug.Variant
require Logger
@impl Image.Plug.VariantStore.Persistence
def load(options) do
path = Keyword.fetch!(options, :path)
provider = Keyword.get(options, :provider, Image.Plug.Provider.Cloudflare)
case File.read(path) do
{:ok, "" <> _ = body} ->
decode_and_parse(body, provider)
{:error, :enoent} ->
# First-boot: no persisted state yet.
{:ok, []}
{:error, reason} ->
{:error, {:read_failed, reason, path}}
end
end
@impl Image.Plug.VariantStore.Persistence
def write(_action, _name, _variant, options) do
# Whole-file rewrite. Read every persistable variant from the
# ETS table and re-serialise. Simpler than journalling and
# bounded by the same total-variants limit (~100 per
# Cloudflare's documented soft cap).
path = Keyword.fetch!(options, :path)
table = Keyword.get(options, :table, default_table())
case dump(table) do
{:ok, json} -> atomic_write(path, json)
{:error, _} = error -> error
end
end
defp default_table, do: Image.Plug.VariantStore.ETS.Server.default_table()
defp dump(table) do
variants =
table
|> :ets.tab2list()
|> Enum.map(fn {_name, variant} -> variant end)
|> Enum.filter(&persistable?/1)
|> Enum.sort_by(& &1.name)
|> Enum.map(&to_json_map/1)
{:ok, IO.iodata_to_binary(:json.encode(variants))}
rescue
e -> {:error, {:dump_failed, e}}
end
# The implicit "public" variant is always re-seeded by the ETS
# server's init/1 — never persisted. Skip silently.
defp persistable?(%Variant{name: "public"}), do: false
defp persistable?(%Variant{options: nil} = variant) do
Logger.warning(
"image_plug: skipping variant #{inspect(variant.name)} during persistence — " <>
"no :options string set. Programmatically-built pipelines need an :options " <>
"string to round-trip through persistence."
)
false
end
defp persistable?(%Variant{options: options}) when is_binary(options) and options != "",
do: true
defp persistable?(_other), do: false
defp to_json_map(%Variant{} = variant) do
%{
"name" => variant.name,
"options" => variant.options,
"metadata" => variant.metadata,
"never_require_signed_urls" => variant.never_require_signed_urls?,
"inserted_at" => datetime_to_iso(variant.inserted_at),
"updated_at" => datetime_to_iso(variant.updated_at)
}
end
defp datetime_to_iso(nil), do: nil
defp datetime_to_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp atomic_write(path, body) do
tmp = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
case File.write(tmp, body) do
:ok -> File.rename(tmp, path)
error -> error
end
end
defp decode_and_parse(body, provider) do
case decode_safe(body) do
{:ok, list} when is_list(list) ->
{:ok, Enum.flat_map(list, &decode_variant(&1, provider))}
{:ok, _other} ->
{:error, :json_root_must_be_array}
{:error, _} = error ->
error
end
end
defp decode_safe(body) do
{:ok, :json.decode(body)}
rescue
e -> {:error, {:json_decode_failed, e}}
end
defp decode_variant(%{"name" => name, "options" => options} = entry, provider)
when is_binary(name) and is_binary(options) do
case parse_options(provider, options) do
{:ok, pipeline} ->
[
%Variant{
name: name,
pipeline: pipeline,
options: options,
metadata: Map.get(entry, "metadata", %{}) |> ensure_map(),
never_require_signed_urls?: !!Map.get(entry, "never_require_signed_urls", false),
inserted_at: parse_iso(Map.get(entry, "inserted_at")),
updated_at: parse_iso(Map.get(entry, "updated_at"))
}
]
{:error, reason} ->
Logger.warning(
"image_plug: skipping persisted variant #{inspect(name)} — could not " <>
"re-parse :options string #{inspect(options)}: #{inspect(reason)}"
)
[]
end
end
defp decode_variant(other, _provider) do
Logger.warning("image_plug: skipping malformed persisted variant entry: #{inspect(other)}")
[]
end
defp parse_options(Image.Plug.Provider.Cloudflare, options_string) do
Image.Plug.Provider.Cloudflare.Options.parse(options_string)
end
defp parse_options(provider, _options_string) do
{:error, {:no_parser_for_provider, provider}}
end
defp parse_iso(nil), do: nil
defp parse_iso(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
_ -> nil
end
end
defp ensure_map(value) when is_map(value), do: value
defp ensure_map(_other), do: %{}
end