DocShell.Artifact (DocShell v0.1.0)

Copy Markdown View Source

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

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

envelope(payload, generated_at \\ DateTime.utc_now(), generation_id \\ new_generation_id())

@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"}
}

new_generation_id()

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

read(path)

@spec read(Path.t()) :: {:ok, term()} | {:error, term()}

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.

read_envelope(path)

@spec read_envelope(Path.t()) :: {:ok, map()} | {:error, term()}

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.

write(path, payload, opts \\ [])

@spec write(Path.t(), term(), keyword()) :: :ok | {:error, term()}

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.

write_raw(path, payload)

@spec write_raw(Path.t(), term()) :: :ok | {:error, term()}

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.