Tama.Dex (tama_dex v0.1.0)

Copy Markdown View Source

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.

Summary

Functions

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

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.

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

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

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

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

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.

Types

cassette()

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

execute_result()

@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()

@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()

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

Functions

cassettes(index)

@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(index, cassette, operation_id, opts)

@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(index, cassette, operation_id, input, opts)

@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(index, path, opts \\ [])

@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 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(index, document, opts)

@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 operationIds, 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(index, query, opts)

@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(index, cassette)

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