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

Index, search and validated execute for operation documents in the Tama
operations profile of OpenAPI 3.1 (`tama-operations/1`).

tama-dex is a library. Loading or starting the `:tama_dex` application
starts no process, opens no database, creates no file and fetches nothing.
A host starts `Tama.Dex.Index` under its own supervisor, with an explicit
database path, and passes the index into every call.

The profile is described in `docs/tama-operations-profile.md`.

## Cassettes

Every source in an index belongs to one cassette, its namespace. A
cassette name matches `^[a-z][a-z0-9_]{0,63}$`, and operations are
addressed by cassette and `operationId`, so two cassettes may carry the
same operation name. A cassette is either local, built by `load/3` from
documents the host holds, or imported from a cassette file. A name belongs
to one kind.

# `cassette`

```elixir
@type cassette() :: String.t()
```

# `execute_result`

```elixir
@type execute_result() ::
  {:ok,
   %{
     cassette: cassette(),
     operation_id: String.t(),
     source: String.t(),
     result: map()
   }}
  | {:error,
     %{
       cassette: cassette(),
       operation_id: String.t(),
       source: String.t(),
       operation_error: map()
     }}
  | {:error, Tama.Dex.Error.t()}
```

# `import_result`

```elixir
@type import_result() ::
  {:ok,
   %{
     cassette: cassette(),
     version: String.t(),
     digest: String.t(),
     mode: :copy | :rebuild,
     changed: boolean(),
     sources: [%{source: String.t(), operations: non_neg_integer()}]
   }}
  | {:error, Tama.Dex.Cassette.verify_error() | :cassette_conflict | :deadline}
```

# `load_error`

```elixir
@type load_error() ::
  {:invalid_document, [Tama.Dex.Profile.Issue.t()]}
  | :cassette_conflict
  | {:duplicate_operation, [%{source: String.t(), operation_ids: [String.t()]}]}
  | :deadline
```

# `cassettes`

```elixir
@spec cassettes(Tama.Dex.Index.t()) :: [map()]
```

Lists the cassettes, ordered by name, with each one's own status.

Each is a map with `cassette`, `kind` (`:local` or `:imported`),
`version`, `status`, `mode` (the import mode, `nil` for a local cassette),
`digest` (the content digest of an imported cassette), `loaded_at` and
`sources`. Each source has `source`, `title`, `version`, `profile`,
`digest`, `operations` (a count) and `loaded_at`.

`status` is `:ready` for a committed cassette, which keeps serving while
it is reloaded or re-imported. A cassette with nothing committed yet is
listed with `{:importing, %{cassette, done, total}}` while its first load
or import runs or waits, or `{:failed, reason}` when that attempt failed,
with `nil` for the other fields and no sources. A host importing several
cassettes can wait until each one is `:ready`.

# `describe`

```elixir
@spec describe(Tama.Dex.Index.t(), cassette(), String.t(), keyword()) ::
  {:ok, map()} | {:error, :not_found | :not_ready}
```

Returns one operation the caller may run, addressed by cassette and
`operationId`, in full: the search result fields with the whole
description, plus `identity`, `success_schema`, `error_schema` and the
operation's opaque `extensions`.

An unknown cassette or operation, or one the caller's `:scopes` (required)
don't allow, returns `{:error, :not_found}`. While the index isn't ready,
or the cassette's first load or import is still running, returns
`{:error, :not_ready}`.

# `execute`

```elixir
@spec execute(Tama.Dex.Index.t(), cassette(), String.t(), term(), keyword()) ::
  execute_result()
```

Validates a call and hands it to the host's executor for the operation's
source, exactly once.

Runs in the caller's process. Each check stops at its first failure, and
no executor is called unless all of them pass:

1. The operation exists in `cassette` (and only there) and the caller's
   `:scopes` allow it, otherwise `:not_found`, which doesn't reveal
   whether a hidden operation or cassette exists. While the index isn't
   ready, or the cassette's first load or import is still running, this
   is `:not_ready` instead.
2. `:executors` has an entry for the operation's source, otherwise
   `:no_executor`. See `Tama.Dex.Executor`.
3. `input` is a JSON object (a map with string keys and JSON values) of at
   most 1 MiB once encoded, otherwise `:invalid_input`.
4. The operation's confirmation field, if it declares one, is present and
   not empty, otherwise `:confirmation_required`. This comes before the
   schema check, so a missing confirmation is never reported as invalid
   input.
5. `input` matches the operation's input schema, otherwise
   `:invalid_input` with the failing JSON pointers.

The executor is then called as `module.execute(call, input)` with a
`Tama.Dex.Call`. tama-dex adds no deadline, and never retries: a raise,
exit or throw returns `:dispatch_failed`. The return value must be
`{:ok, result}` matching the `200` schema, or `{:error, error}` matching
`default` when declared, each a JSON object; anything else returns
`:invalid_output`, carrying only the operation's `x-tama-identity` values.

## Options

- `:scopes` (required): the caller's verified scopes, passed to the
  executor as given.
- `:executors` (required): `%{source => module | {module, opts}}`.
- `:context`: any host term, passed to the executor as `call.context`.

## Returns

- `{:ok, %{cassette, operation_id, source, result}}`;
- `{:error, %{cassette, operation_id, source, operation_error}}`: the
  executor's own valid error, unchanged. The operation ran;
- `{:error, %Tama.Dex.Error{}}`: a tama-dex error, whose `dispatched`
  says whether the executor was called. Match this form first.

Inputs and results are never stored, logged or sent in telemetry.

# `import`

```elixir
@spec import(Tama.Dex.Index.t(), Path.t(), keyword()) ::
  import_result() | {:ok, reference()}
```

Installs a cassette file, replacing the whole cassette of that name in one
transaction. Other cassettes don't change.

The file is verified first, as `Tama.Dex.Cassette.verify/2` does, and is
installed under its manifest name or `:as`. A name held by a local
cassette fails with `:cassette_conflict`.

- **Copy mode** copies the cassette's prebuilt rows. It is used only when
  `:sha256` matches the file and the cassette has this index's schema
  version.
- **Rebuild mode** writes the cassette's verified documents, as `load/3`
  would. It is used otherwise, so an unpinned cassette can't install rows
  that differ from its documents.

Importing the same content digest again writes nothing and reports
`changed: false`. tama-dex never downloads cassettes: `path` is a local
file the host supplies, and it should be one the host controls, such as a
file bundled at build time.

## Options

- `:sha256`: the file's expected SHA-256, the pin that allows copy mode.
- `:as`: install under this name instead of the manifest's.
- `:timeout`: a deadline in milliseconds, as for `load/3`. When it
  passes, the import rolls back and returns `{:error, :deadline}`.
- `:async`: when `true`, returns `{:ok, ref}` once the file has passed
  its size, SHA-256, header, schema and manifest checks, and imports in
  the index's writer. The calling process then receives
  `{:tama_dex_import, ref, result}`, where `result` is what a synchronous
  import returns.

Returns `{:ok, %{cassette, version, digest, mode, changed, sources}}`,
with each source's `source` and `operations` (a count), or
`{:error, reason}` with a `t:Tama.Dex.Cassette.verify_error/0`,
`:cassette_conflict` or `:deadline`. Emits the
`[:tama_dex, :import, :start | :stop | :exception]` span, whose metadata
never includes the path or document content.

# `load`

```elixir
@spec load(Tama.Dex.Index.t(), term(), keyword()) ::
  {:ok,
   %{
     cassette: cassette(),
     source: String.t(),
     operations: non_neg_integer(),
     changed: boolean()
   }}
  | {:error, load_error()}
```

Validates a profile document and installs it as one source of the local
cassette named by the required `:cassette` option.

The document is validated in full first, under the index's limits. The
source's previous entries are then replaced in one transaction, so readers
see either the old set or the new one. Loading a document whose
canonical-JSON digest is already installed for that source changes
nothing and reports `changed: false`.

Returns `{:ok, %{cassette, source, operations, changed}}`, where
`operations` is the number of operations in the document, or one of:

- `{:error, {:invalid_document, issues}}` when validation fails;
- `{:error, :cassette_conflict}` when the name belongs to an imported
  cassette;
- `{:error, {:duplicate_operation, conflicts}}` when another source of the
  same cassette owns some of the document's `operationId`s, with each
  owning source and its conflicting IDs.

A failed load changes nothing.

## Options

- `:cassette` (required): the local cassette's name.
- `:timeout`: a deadline in milliseconds. When it passes, the load rolls
  back, the previous entries stay, and it returns `{:error, :deadline}`.
  The deadline is checked between steps, so the call returns at the first
  check after it. Without `:timeout`, the call waits until the load
  finishes, with no default timeout.

While a load runs, `Tama.Dex.Index.status/1` reports it. Raises
`ArgumentError` when `:cassette` is missing or not a valid cassette name,
or `:timeout` is invalid.

# `search`

```elixir
@spec search(Tama.Dex.Index.t(), String.t() | nil, keyword()) ::
  {:ok, %{results: [map()], truncated: boolean()}} | {:error, :not_ready}
```

Searches the operations the caller may run, across every cassette in one
ranking.

`query` is caller text of at most 512 bytes, or `nil`. It is reduced to
terms of letters and digits, each matched as a prefix, all of which must
match. FTS5 tokenizes the terms as it tokenized the indexed text, so text
in any script matches. Raw FTS5 syntax never reaches SQLite, so quotes,
`NEAR`, `*`, `:`, `-` and `OR` in the text are inert. Text with no term lists operations in
`(cassette, operation_id)` order.

Matches are ranked by operation ID, then summary, tags, description and
input fields. An `operationId` equal to the whole query ranks first, and
ties are ordered by cassette, then `operationId`.

## Options

- `:scopes` (required): the caller's verified scopes. Only operations
  whose every scope is among them are visible, and hidden operations never
  affect results, counts or flags.
- `:limit`: 1 to 20, default 5.
- `:cassette`, `:source`: only operations in that cassette or source.
- `:tags`: only operations carrying every listed tag.
- `:include_deprecated` (default `false`), `:include_destructive`
  (default `true`).
- `:include_schemas` (default `true`): include each result's input schema.

Raises `ArgumentError` for a missing `:scopes`, a query over 512 bytes, a
limit outside 1 to 20, or another invalid option.

While no cassette is committed and a load or import runs, returns
`{:error, :not_ready}` at once (see `Tama.Dex.Index.status/1`).

Returns `{:ok, %{results: results, truncated: truncated}}`. Each result has
`cassette`, `cassette_version`, `operation_id`, `source`, `summary`,
`description` (cut to 1024 characters, with `description_truncated`),
`tags`, `scopes`, `destructive`, `deprecated`, `confirmation`, `bounds` and
`input_schema`. The encoded response stays within 64 KiB: input schemas
are dropped from the lowest-ranked results first, each marked
`schema_omitted: true`, and `truncated` is `true` when results themselves
had to be cut.

# `unload`

```elixir
@spec unload(Tama.Dex.Index.t(), cassette()) :: :ok
```

Removes a cassette and every source and operation in it. Removing a
cassette that isn't installed is a no-op. Raises `ArgumentError` for an
invalid cassette name.

---

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