# `Tama.Dex.Cassette`
[🔗](https://github.com/kritama/tama-dex/blob/v0.1.0/lib/tama/dex/cassette.ex#L1)

Builds and verifies cassettes: portable, prebuilt index files in the
`tama-cassette/1` format, each holding one cassette.

A producer's CI builds a cassette from its profile documents, usually with
`mix tama.dex.cassette`, and publishes it with its SHA-256. A host
installs it with `Tama.Dex.import/3` from a local file it supplies.
tama-dex never downloads cassettes.

A cassette holds the index's storage tables for its one cassette, a
manifest (format, cassette name and version, tama-dex version, index
schema version, SQLite version, profile and content digest) and each
source's canonical document with its digest. It stores no wall-clock time.

## Identity

The content digest covers the format, the cassette name and version, the
index schema version and each source's document digest. Building the same
documents again gives the same content digest, even where SQLite writes
different bytes, so the content digest identifies a cassette and the file
SHA-256 pins one file.

## Trust

`verify/2` treats a cassette as untrusted. Its prebuilt rows are copied
into an index only when the host pins the file's SHA-256 (see
`Tama.Dex.import/3`). Otherwise the index is rebuilt from the embedded
documents after their digests and profile validity are checked.

# `build_error`

```elixir
@type build_error() ::
  :no_documents
  | :too_large
  | {:invalid_document, non_neg_integer(), [Tama.Dex.Profile.Issue.t()]}
  | {:duplicate_source, String.t()}
  | {:duplicate_operation, [%{source: String.t(), operation_ids: [String.t()]}]}
```

# `manifest`

```elixir
@type manifest() :: %{
  format: String.t(),
  name: String.t(),
  version: String.t(),
  tama_dex_version: String.t(),
  index_schema_version: integer(),
  sqlite_version: String.t(),
  profile: String.t(),
  content_digest: String.t(),
  sha256: String.t(),
  sources: [
    %{source: String.t(), digest: String.t(), operations: non_neg_integer()}
  ]
}
```

# `verify_error`

```elixir
@type verify_error() ::
  {:file, File.posix()}
  | :too_large
  | :sha256_mismatch
  | :not_a_cassette
  | {:unexpected_schema, [String.t()]}
  | :invalid_manifest
  | {:document_digest_mismatch, String.t()}
  | {:invalid_document, String.t(), [Tama.Dex.Profile.Issue.t()]}
  | {:duplicate_operation, [map()]}
  | :content_digest_mismatch
```

Why a cassette was rejected. Names and sources read from the file are
bounded.

- `{:file, posix}`: the file can't be read.
- `:too_large`: over 64 MiB, checked before the file is opened.
- `:sha256_mismatch`: the file doesn't match the host's pin.
- `:not_a_cassette`: not a SQLite file in rollback-journal mode, or
  corrupt.
- `{:unexpected_schema, names}`: triggers, views, or tables and indexes
  other than the expected ones, checked before any row is read.
- `:invalid_manifest`: a missing or malformed manifest, or no documents.
- `{:document_digest_mismatch, source}`: an embedded document doesn't
  match its digest.
- `{:invalid_document, source, issues}`: an embedded document isn't a
  valid profile document.
- `{:duplicate_operation, conflicts}`: sources sharing operation IDs.
- `:content_digest_mismatch`: the manifest's content digest is wrong.

# `build`

```elixir
@spec build([term()], Path.t(), keyword()) :: {:ok, map()} | {:error, build_error()}
```

Builds a cassette file at `path` from profile documents.

Every document is validated first, and sources or operation IDs repeated
within the cassette are rejected, before anything is written. The file is
written to a temporary file in the same directory, compacted with
`VACUUM` and renamed into place, replacing any file at `path`. A file
larger than `verify/2` accepts is discarded instead, leaving `path`
untouched.

## Options

- `:name` (required): the cassette name, matching `^[a-z][a-z0-9_]{0,63}$`.
- `:version` (required): the cassette version, at most 64 bytes, usually
  the producer's release version.
- `:max_bytes`: a lower size limit than the 64 MiB `verify/2` accepts.

Returns `{:ok, %{path, cassette, version, digest, sha256, sources}}`,
where `digest` is the content digest, `sha256` the file's SHA-256, and
each source has `source` and `operations` (a count). Fails with
`{:error, :no_documents}` for an empty list,
`{:error, {:invalid_document, position, issues}}` (position counts from
0), `{:error, {:duplicate_source, source}}`,
`{:error, {:duplicate_operation, conflicts}}`, or `{:error, :too_large}`
when the file would be over the size limit. Raises `ArgumentError` for
a missing or invalid name, version or path.

# `verify`

```elixir
@spec verify(Path.t(), keyword()) :: {:ok, manifest()} | {:error, verify_error()}
```

Verifies a cassette file without installing it.

Checks, in order: the size (64 MiB at most) before opening, the file
SHA-256 against `:sha256` when given, the SQLite header, the schema
objects (no triggers or views, and exactly the expected tables and
indexes), the database's integrity, the manifest, every embedded
document's digest and profile validity, and the content digest. The file
is read once, into memory, and every check reads that copy, so a file
replaced during verification can't pass with other contents. SQLite opens
the copy as a private in-memory database, with extension loading off and
queries only.

## Options

- `:sha256`: the expected lowercase hex SHA-256 of the file.
- `:max_bytes`: a lower size limit.

Returns `{:ok, manifest}`, with the file's `sha256` and its `sources`,
or `{:error, reason}` (see `t:verify_error/0`).

---

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