# `DocShell.Artifact`
[🔗](https://github.com/futhr/doc_shell/blob/v2.0.0/lib/doc_shell/artifact.ex#L1)

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/v2",
      "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.

## Canonical bytes

Files hold compact canonical JSON: object keys sorted by UTF-8 bytes and no
insignificant whitespace, as produced by `DocShell.Json.Canonical`. The
bytes on disk are therefore exactly the bytes collection digests are
computed over, and two builds of the same content differ only in their
envelope fields. Pass `pretty: true` to `write/3`, or `pretty: true` to
`DocShell.Build.run/1`, when a person needs to read the files; decoded
values and digests do not change.

`encode/2` and `encode_envelope/4` return a `DocShell.Artifact.Encoded`
value. A build encodes each payload once, hashes those bytes, and splices
them into the envelope, so no artifact is encoded twice.

## 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. The temporary is opened in
raw mode and the encoded iodata is written to it directly, without first
being copied into one binary or through a file server process.

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

# `decode_envelope`

```elixir
@spec decode_envelope(binary()) :: {:ok, map()} | {:error, term()}
```

Decodes a JSON envelope, rejecting ambiguous duplicate object keys.

# `encode`

```elixir
@spec encode(term(), keyword()) ::
  {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
```

Encodes a payload as the bytes of an enveloped artifact file.

Accepts the same `:generated_at`, `:generation_id` and `:pretty` options as
`write/3`, and returns exactly the bytes `write/3` would write.

# `encode_envelope`

```elixir
@spec encode_envelope(iodata(), DateTime.t(), String.t(), keyword()) ::
  {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
```

Wraps payload bytes that are already canonical JSON in an artifact envelope.

`data` must come from `DocShell.Json.Canonical.encode_to_iodata/1`. The
envelope keys sort after `"data"`, so splicing the bytes in yields the same
canonical document as encoding the whole envelope, without encoding the
payload a second time. Pass `pretty: true` for indented output.

# `encode_raw`

```elixir
@spec encode_raw(term(), keyword()) ::
  {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
```

Encodes a term as the bytes `write_raw/3` would write, with no envelope.

An `Encoded` payload is returned unchanged. Accepts `pretty: true`.

# `envelope`

```elixir
@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/v2",
      "generated_at" => "2026-08-05T09:12:44Z",
      "generation_id" => "example-generation",
      "data" => %{"id" => "intro"}
    }

# `new_generation_id`

```elixir
@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`

```elixir
@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`

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

Reads an artifact and returns the whole validated envelope.

Current-schema envelopes without `generation_id` remain readable. When
present, the ID must be a nonempty string. Previous schema versions,
including `doc-shell/v1`, are rejected. `DocShell.Web.Cache` requires the ID
on every file because a runtime snapshot needs a verifiable build identity.

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`

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

Writes a payload to `path` as an enveloped canonical 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.

The file holds compact canonical JSON (see `DocShell.Json.Canonical`). Pass
`pretty: true` to indent it for reading by eye; the decoded value, and every
digest computed over it, is the same either way.

Invalid JSON values, duplicate encoded keys and exceptions from custom Jason
encoders return tagged errors before replacing the destination. Protocol output
is checked as JSON; an unsafe fragment cannot publish malformed bytes.

# `write_raw`

```elixir
@spec write_raw(Path.t(), term(), keyword()) :: :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. The term is written as
canonical JSON, or indented with `pretty: true`. A `DocShell.Artifact.Encoded`
payload is written verbatim.

Note that `DocShell.Web.Cache` rejects a directory containing an
unenveloped `.json` file, so these belong outside the artifact directories.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
