Reads and writes the versioned JSON envelope every artifact is wrapped in.
A bare payload on disk tells a reader nothing about which version of DocShell produced it or when. Every artifact is therefore wrapped:
{
"schema_version": "doc-shell/v1",
"generated_at": "2026-08-05T09:12:44.000000Z",
"generation_id": "Lve95gjOVATpfV8EL5X4nx",
"data": { ... }
}read/1 unwraps that and hands back only data, having first checked that
the version matches what this build of DocShell understands. A file written
by a future schema comes back as {:error, :unsupported_schema_version}
rather than as plausible-looking data with fields quietly missing — which is
the failure mode worth engineering against, since artifacts are read by
renderers in other repositories on their own release cadence.
Atomic writes
write/2 writes to a temporary file in the destination directory and renames
it into place. A reader — typically DocShell.Web.Cache reloading while a
build runs — therefore sees either the previous artifact or the complete new
one, never a half-written file. The rename is atomic on POSIX filesystems
within a single filesystem, which is why the temporary lives beside its
target rather than in the system temp directory.
Usage
:ok = DocShell.Artifact.write("priv/doc_shell/public/guides.json", entries)
{:ok, ^entries} = DocShell.Artifact.read("priv/doc_shell/public/guides.json")Producers should go through this module rather than encoding JSON themselves, so that a schema-version bump is one change instead of one per artifact.
Summary
Functions
Wraps a payload in the public artifact envelope.
Creates an opaque identifier for one complete artifact generation.
Reads an artifact and returns its payload with the envelope removed.
Reads an artifact and returns the whole validated envelope.
Writes a payload to path as a pretty-printed, enveloped JSON artifact.
Writes a term to path as JSON with no envelope around it.
Functions
@spec envelope(term(), DateTime.t(), String.t()) :: map()
Wraps a payload in the public artifact envelope.
generated_at defaults to now and is accepted explicitly so a caller
stamping several artifacts in one build can give them all the same timestamp.
generation_id identifies the complete build that the artifact belongs to;
pass the same value for every file in one artifact tree.
Examples
iex> DocShell.Artifact.envelope(
...> %{"id" => "intro"},
...> ~U[2026-08-05 09:12:44Z],
...> "example-generation"
...> )
%{
"schema_version" => "doc-shell/v1",
"generated_at" => "2026-08-05T09:12:44Z",
"generation_id" => "example-generation",
"data" => %{"id" => "intro"}
}
@spec new_generation_id() :: String.t()
Creates an opaque identifier for one complete artifact generation.
Build pipelines should create one identifier and pass it to every
write/3 call in that generation. The value carries no ordering semantics.
Reads an artifact and returns its payload with the envelope removed.
Fails with {:error, :unsupported_schema_version} when the file was written
by a different contract version, and {:error, :invalid_artifact_envelope}
when the envelope is missing keys or carries an unparseable timestamp. File
and JSON errors are returned unchanged from File.read/1 and Jason.
Reads an artifact and returns the whole validated envelope.
Use this over read/1 when the envelope itself matters — serving an artifact
has to report the generated_at of the build that produced it, and
re-enveloping a bare payload would stamp it with the time of the request
instead.
Writes a payload to path as a pretty-printed, enveloped JSON artifact.
Creates the destination directory if needed, and swaps the file into place
with a rename so concurrent readers never observe a partial write. Pass
:generated_at and :generation_id when writing a complete tree so every
file carries the same build identity.
Writes a term to path as JSON with no envelope around it.
For documents that have to satisfy an external format rather than this
package's contract — an OpenAPI file a UI is pointed at, say. Artifacts a
renderer reads should go through write/2 instead.
Note that DocShell.Web.Cache rejects a directory containing an
unenveloped .json file, so these belong outside the artifact directories.