# `Trellis`
[🔗](https://github.com/salesforce-misc/trellis/blob/v0.1.0-alpha/clients/elixir/lib/trellis.ex#L1)

Embedded Trellis for Elixir: define streaming transforms over Postgres
tables from the BEAM, through the `trellis` Rust crate itself
(`docs/decisions/0010-embeddable-clients.md`).

The surface mirrors the Rust crate's `BlockingTrellis`:

- **Lifecycle:** `connect/1`, `migrate/1`, `config/1`, `shutdown/1`, and
  `start_link/1` for a handle a supervisor owns.
- **Every statement form:** `apply/2` runs one statement of Trellis's
  grammar (`TRANSFORM`, `PAUSE TRANSFORM`, `RESUME TRANSFORM`,
  `DROP TRANSFORM`, `ALTER TRANSFORM`) and reports what it did as a `t:Trellis.Applied.t/0`.
  `define/2` is the one guarded convenience: an `apply/2` that refuses
  anything but a `TRANSFORM` statement before applying it.
- **Reads:** `status/2`, `definitions/1`, and
  `dump_definitions_sql/1`, the definitions as SQL to append to a schema dump.
- **Quarantine:** `quarantined/1`, `quarantine_status/2`,
  `sample_quarantined/3`, `poisoned_since/2`. Pausing and resuming are
  statements: `apply(trellis, "RESUME TRANSFORM order_totals.total")`.
- **Operations:** `request_backfill/2`, `detect_capture_disruptions/1`,
  `has_live_drain_workers/1`,
  `has_live_staging_worker/1`, the read-your-writes pair
  `watermark_token/1` and `await_converged/3`, and the audit
  `self_check/3`, a background job that `self_check_job/2` reads back.
- **Observability:** `Trellis.Metrics.render_prometheus/0` for a scrape
  route the host serves, and `Trellis.LogBridge`, which forwards the
  engine's log lines to `Logger` from the moment `:trellis_pg` starts.

    # A deploy's migration step: the defaults run nothing in the background.
    {:ok, migrator} = Trellis.connect(url: "postgres://localhost/app")
    :ok = Trellis.migrate(migrator)
    :ok = Trellis.shutdown(migrator)

    # The app, once migrated. The staging worker reads the catalog as it
    # starts, so a `staging: true` handle can't connect before `migrate/1`.
    {:ok, trellis} = Trellis.connect(url: "postgres://localhost/app", staging: true, drain_threads: 2)
    {:ok, "public.widget_prices"} = Trellis.define(trellis, "TRANSFORM widget_prices FROM widgets SELECT price AS price")
    {:ok, %Trellis.Status{status: :live}} = Trellis.status(trellis, "widget_prices")
    :ok = Trellis.shutdown(trellis)

Every function here blocks the calling process until the engine answers,
and holds no scheduler while it waits: the call is submitted and the
process waits in a `receive`, so a stuck call costs the process nothing but
its own wait, and killing the process ends the wait at once. Every call
returns within 30 seconds (a `:timeout` error if the database is too slow);
see "Calls" below. Non-bang functions
return `{:ok, value}` (or `:ok`) and `{:error, %Trellis.Error{}}`; the bang
variants return the value or raise the `Trellis.Error`.

## Conventions

- Times are `DateTime`s in UTC, at microsecond precision.
- A transform is named by its target table, bare (`"order_totals"`) or
  qualified as `define/2` returns it (`"public.order_totals"`), in `status/2`,
  `quarantine_status/2`, `sample_quarantined/3`, `release_key/4`,
  `self_check/3` and the `PAUSE`, `RESUME`, `DROP` and `ALTER` statements.
- A quarantine target is an address string: a transform's name, or
  `"transform.column"` (`"order_totals.total"`, always the bare transform),
  exactly as `quarantined/1` reports it.
- `sample_quarantined/3`'s cursor and `watermark_token/1`'s token are opaque: pass back what the previous call
  returned.
- Every atom in a result comes from a closed set allocated when the NIF
  loads; none is ever built from a string the database returned.

## One handle per instance

An instance is one catalog schema in one database (the `:schema` option). A
handle belongs to one instance, and a VM holds a handle for each instance it
uses: against different databases, or against different catalog schemas in
one database. Connect each handle once, at boot, and share it; don't connect
per request. The handle is shut down when `shutdown/1` is called, or, as a
backstop, when it is garbage collected.

Each handle pays for itself. It owns a connection pool of up to 20
connections and a small Rust runtime (`:worker_threads` threads). A handle
with `staging: true` or `drain_threads` above `0` also owns a second runtime
and pool of the same size, plus connections outside both pools: one for the
staging worker, and for each drain thread a `LISTEN` connection and up to two
more while it drains.
Handles share no budget, so size all of them together against the server's
`max_connections`. Each instance has its own staging worker, so set
`staging: true` in one process of the fleet per instance.

In an application, let a supervisor own each handle: `{Trellis, options}` in
the supervision tree starts a process that connects the handle as it starts
and shuts it down when the supervisor stops it (see `start_link/1`). Every
function here that takes a handle takes that process's name too, and the
names tell the handles apart:

    # config/runtime.exs
    config :my_app, MyApp.Trellis,
      name: MyApp.Trellis,
      url: System.fetch_env!("DATABASE_URL")

    # lib/my_app/application.ex
    children = [
      MyApp.Repo,
      {Trellis, Application.fetch_env!(:my_app, MyApp.Trellis)},
      MyAppWeb.Endpoint
    ]

    # Anywhere in the app.
    {:ok, status} = Trellis.status(MyApp.Trellis, "widget_prices")

A second instance in the same database is a second child with its own `:name`
and `:schema`:

    {Trellis, name: MyApp.Billing, url: url, schema: "billing"}

## Calls

A call returns within 30 seconds, counted from when it is submitted: if the
database can't answer in time (a lock held by another session, say), the
server stops what the call started, it rolls back, and it returns
`{:error, %Trellis.Error{code: :timeout}}`. Heavy work isn't done inside a
call: `define/2` and `apply/2` register and return, and a caller that
needs the result polls `status/2`.

Calls run in parallel, on one handle and across processes: a call stuck on
a lock doesn't delay another process's `status/2`. A process's own calls
stay in order, because it waits for each reply. Calls from different
processes have no order. A process killed during a call leaves the call
running until it ends or reaches its deadline, and its reply is dropped.

`shutdown/1` doesn't wait for calls in flight: it cancels them, and each
of their callers gets a `:validation` error, as a call made after the
shutdown does.

`Trellis.Metrics.render_prometheus/0` covers every handle in the VM at once,
each instance's series labeled `trellis_instance`.

For the migrations that define transforms, see `Trellis.Migration`.

# `option`

```elixir
@type option() ::
  {:url, String.t()}
  | {:schema, String.t()}
  | {:target_schema, String.t()}
  | {:staging, boolean()}
  | {:drain_threads, non_neg_integer()}
  | {:worker_threads, pos_integer()}
```

Options for `connect/1`:

- `:url` (required): the database, as a `postgres://` URL or a libpq
  `key=value` string. Nothing is read from the environment.
- `:schema`: the schema Trellis keeps its own tables in. Default `"trellis"`.
  Not `"public"`, not `:target_schema`, and not another instance's catalog
  or target schema; `migrate/1` refuses those.
- `:target_schema`: the schema a bare target table name is created in.
  Default `"public"`.
- `:staging`: whether this connection runs the staging worker, which
  installs change capture on the source tables and starts each new
  transform's backfill. Exactly one connection of each instance in a fleet
  should. Default `false`.
- `:drain_threads`: how many threads apply staged changes to the targets.
  Default `0`.
- `:worker_threads`: the worker threads of each Rust runtime a handle owns
  (its calls', and its background client's). Default `2`; the
  work is IO, so a small count is enough, and the BEAM has already sized
  its own schedulers to the cores.

The defaults (`staging: false, drain_threads: 0`) run nothing in the
background, which is right for a web process or a migration run. Some
connection in the fleet has to set both, or no definition ever leaves
`:waiting_to_backfill`.

# `sample_option`

```elixir
@type sample_option() ::
  {:limit, pos_integer()} | {:after, Trellis.SamplePage.cursor() | nil}
```

Options for `sample_quarantined/3`:

- `:limit`: the most rows to return. Default `100`.
- `:after`: the `next_cursor` of the previous page. Default `nil`, the
  first page.

# `self_check_option`

```elixir
@type self_check_option() ::
  {:timeout_ms, non_neg_integer()} | {:mode, :standard | :strict}
```

Options for `self_check/3`:

- `:timeout_ms` (required): how long a page of the job waits for the
  target to catch up, per wait. A `:standard` check waits up to twice per
  page, a `:strict` one once.
- `:mode`: `:standard` (default) re-checks anything that differs after a
  fresh wait, so a change still in flight isn't reported; it is safe while
  the source is being written. `:strict` skips the re-check, and is only
  sound once writes to the audited tables have stopped.

# `start_option`

```elixir
@type start_option() :: option() | {:name, GenServer.name()}
```

Options for `start_link/1`: every `t:option/0`, plus `:name`, the name to
register the process under (see `GenServer.start_link/3`). Without one,
call it by its pid.

# `t`

```elixir
@opaque t()
```

# `trellis`

```elixir
@type trellis() :: t() | GenServer.server()
```

What every function but `connect/1` takes: a handle `connect/1` returned,
or the name or pid of a supervised Trellis (`start_link/1`).

# `watermark`

```elixir
@opaque watermark()
```

An opaque `watermark_token/1` token.

# `apply`

```elixir
@spec apply(trellis(), String.t()) ::
  {:ok, Trellis.Applied.t()} | {:error, Trellis.Error.t()}
```

Runs one statement of Trellis's grammar, whatever its form, and reports
what it did (see `t:Trellis.Applied.t/0`).

    {:ok, {:transform_defined, "public.order_totals"}} =
      Trellis.apply(trellis, "TRANSFORM order_totals FROM orders SELECT price + tax AS total")

    {:ok, {:resumed, ["order_totals.total"]}} =
      Trellis.apply(trellis, "RESUME TRANSFORM order_totals.total")

A statement that doesn't parse is a `:parse` error, and nothing is applied.
Like `define/2`, this runs on Trellis's own connections, not in the
caller's transaction.

`{:ok, :unknown}` is a success like any other `{:ok, _}`: the statement
took effect, and only its outcome is newer than this binding can describe.
Don't retry it; read the result back with `status/2` or `definitions/1` if
you need it.

# `apply!`

```elixir
@spec apply!(trellis(), String.t()) :: Trellis.Applied.t()
```

Like `apply/2`, but raises `Trellis.Error`.

# `await_converged`

```elixir
@spec await_converged(trellis(), watermark(), non_neg_integer()) ::
  :ok | {:error, Trellis.Error.t()}
```

Waits until every change committed at or before `token` has reached its
target tables, or `timeout_ms` passes (a `:timeout` error; retry it).

It waits for captured changes only: a transform that isn't `:live` yet
may still be missing rows when this returns (see `status/2`).

A call returns within 30 seconds, so a longer `timeout_ms` is cut short
with a `:timeout` error: to wait longer, call again. The wait holds no
scheduler, and other calls on the handle run beside it.

# `await_converged!`

```elixir
@spec await_converged!(trellis(), watermark(), non_neg_integer()) :: :ok
```

Like `await_converged/3`, but raises `Trellis.Error`.

# `child_spec`

```elixir
@spec child_spec([start_option()]) :: Supervisor.child_spec()
```

The child spec `{Trellis, options}` stands for in a supervision tree. Its
id is the `:name` option, so one tree can hold several.

# `config`

```elixir
@spec config(trellis()) :: {:ok, Trellis.Config.t()} | {:error, Trellis.Error.t()}
```

The configuration `trellis` connected with. See `Trellis.Config`.

# `config!`

```elixir
@spec config!(trellis()) :: Trellis.Config.t()
```

Like `config/1`, but raises `Trellis.Error`.

# `connect`

```elixir
@spec connect([option()] | %{optional(atom()) =&gt; term()}) ::
  {:ok, t()} | {:error, Trellis.Error.t()}
```

Connects to the database and starts whatever background work `options`
asks for. See `t:option/0`.

# `connect!`

```elixir
@spec connect!([option()] | %{optional(atom()) =&gt; term()}) :: t()
```

Like `connect/1`, but raises `Trellis.Error`.

# `define`

```elixir
@spec define(trellis(), String.t()) :: {:ok, String.t()} | {:error, Trellis.Error.t()}
```

Registers a `TRANSFORM` statement and creates its target table.

Returns the transform's fully qualified target table once the definition is
registered and its backfill queued, with the transform at
`:waiting_to_backfill`; poll `status/2` with the returned name for `:live`.

Only `TRANSFORM` statements belong here. Any other statement form (`DROP`,
`PAUSE`, `ALTER`, ...) is refused with a `:validation` error before
anything is applied, and a statement that doesn't parse is a `:parse` error.
`apply/2` takes every form.

Trellis runs this on its own connections, not in the caller's transaction:
if an enclosing Ecto migration rolls back, the definition stays.

# `define!`

```elixir
@spec define!(trellis(), String.t()) :: String.t()
```

Like `define/2`, but raises `Trellis.Error`.

# `definitions`

```elixir
@spec definitions(trellis()) ::
  {:ok, [Trellis.DefinitionSummary.t()]} | {:error, Trellis.Error.t()}
```

Every registered transform definition, oldest first.

# `definitions!`

```elixir
@spec definitions!(trellis()) :: [Trellis.DefinitionSummary.t()]
```

Like `definitions/1`, but raises `Trellis.Error`.

# `detect_capture_disruptions`

```elixir
@spec detect_capture_disruptions(trellis()) ::
  {:ok, [String.t()]} | {:error, Trellis.Error.t()}
```

Pauses, now, the definitions that read a table whose Trellis capture was
changed outside Trellis (a trigger disabled, dropped or recreated, a
capture function replaced, the table gone), and returns the qualified
names of those tables.

The staging worker's reconcile pass does the same on its own cadence, with
a `Trellis.CaptureFailure` of kind `:disruption` on each paused
definition's status. This is for a caller that has just done something
that disrupts capture, like a fixture load, and needs the readers paused
before it goes on. It installs, repairs and rebuilds nothing, and resuming
is still the caller's (`Trellis.TestSupport` does it for a test suite).
Safe beside a running staging worker.

# `detect_capture_disruptions!`

```elixir
@spec detect_capture_disruptions!(trellis()) :: [String.t()]
```

Like `detect_capture_disruptions/1`, but raises `Trellis.Error`.

# `dump_definitions_sql`

```elixir
@spec dump_definitions_sql(trellis()) ::
  {:ok, String.t()} | {:error, Trellis.Error.t()}
```

The SQL that loads this instance's definitions into a database built from
a schema-only dump (`pg_dump --schema-only`, `mix ecto.dump`). Append it
to the dump; `mix trellis.dump` does.

It holds `INSERT`s for the rows that say what is defined (Trellis's
migration history, its instance marker, the transform definitions and the catalog rows they need), and nothing else: the ledger,
the ring, backfill progress, quarantine and claims describe rows a
schema-only dump doesn't carry. Every definition loads as "defined, not
built" (`:waiting_to_backfill`), the state a `define/2` commits, including
one that was paused or quarantined where it was dumped. The rows are one
repeatable-read snapshot of the database. `migrate/1` on the loaded
database does nothing.

# `dump_definitions_sql!`

```elixir
@spec dump_definitions_sql!(trellis()) :: String.t()
```

Like `dump_definitions_sql/1`, but raises `Trellis.Error`.

# `has_live_drain_workers`

```elixir
@spec has_live_drain_workers(trellis()) ::
  {:ok, boolean()} | {:error, Trellis.Error.t()}
```

Whether at least one drain worker is alive anywhere in the fleet. With
none, nothing reaches a target table: poll this from a health check.

# `has_live_drain_workers!`

```elixir
@spec has_live_drain_workers!(trellis()) :: boolean()
```

Like `has_live_drain_workers/1`, but raises `Trellis.Error`.

# `has_live_staging_worker`

```elixir
@spec has_live_staging_worker(trellis()) ::
  {:ok, boolean()} | {:error, Trellis.Error.t()}
```

Whether the staging worker (which installs change capture and starts
backfills) is alive anywhere in the fleet. The other half of the health check.

# `has_live_staging_worker!`

```elixir
@spec has_live_staging_worker!(trellis()) :: boolean()
```

Like `has_live_staging_worker/1`, but raises `Trellis.Error`.

# `migrate`

```elixir
@spec migrate(trellis()) :: :ok | {:error, Trellis.Error.t()}
```

Creates or upgrades Trellis's own tables in the configured schema. Safe to
run on every boot.

Run it on a handle with the default options, before connecting one that
runs background work: the staging worker reads Trellis's tables as it
starts, so a `staging: true` connect to an unmigrated schema fails with a
`:not_found` error.

# `migrate!`

```elixir
@spec migrate!(trellis()) :: :ok
```

Like `migrate/1`, but raises `Trellis.Error`.

# `poisoned_since`

```elixir
@spec poisoned_since(trellis(), DateTime.t()) ::
  {:ok, [Trellis.PoisonEntry.t()]} | {:error, Trellis.Error.t()}
```

Every source row the apply path poisoned (gave up on) after `since`,
oldest first. Poll it with the last entry's `poisoned_at` so a
whole-table failure doesn't sit unnoticed.

# `poisoned_since!`

```elixir
@spec poisoned_since!(trellis(), DateTime.t()) :: [Trellis.PoisonEntry.t()]
```

Like `poisoned_since/2`, but raises `Trellis.Error`.

# `quarantine_status`

```elixir
@spec quarantine_status(trellis(), String.t()) ::
  {:ok, Trellis.QuarantineEntry.t()} | {:error, Trellis.Error.t()}
```

The state of one target: a transform (`"order_totals"`) or one of its
columns (`"order_totals.total"`). A column that isn't paused is `:live`.

# `quarantine_status!`

```elixir
@spec quarantine_status!(trellis(), String.t()) :: Trellis.QuarantineEntry.t()
```

Like `quarantine_status/2`, but raises `Trellis.Error`.

# `quarantined`

```elixir
@spec quarantined(trellis()) ::
  {:ok, [Trellis.QuarantineEntry.t()]} | {:error, Trellis.Error.t()}
```

Every quarantined transform and paused column, across every transform.
Cheap enough for a dashboard or health check to poll.

# `quarantined!`

```elixir
@spec quarantined!(trellis()) :: [Trellis.QuarantineEntry.t()]
```

Like `quarantined/1`, but raises `Trellis.Error`.

# `release_key`

```elixir
@spec release_key(trellis(), String.t(), String.t(), String.t()) ::
  :ok | {:error, Trellis.Error.t()}
```

Releases one key `transform` holds in quarantine, once its cause is fixed.
`source_table` (`"public.orders"` or `"orders"`) and `key` are as
`sample_quarantined/3` and `poisoned_since/2` report them.

It stages a recompute of the key, which every transform reading the table
applies from the key's current row, and discards the changes held for
`transform` meanwhile. Another transform holding the same key keeps
holding it. If the cause is still there, the key is poisoned again.
Resuming the transform releases every key it holds.

An unknown `transform` is a `:not_found` error, and so is a key it doesn't
hold, which changes nothing. The release first waits for the drain pages in
flight on the table to commit; a wait past the lock timeout (30 seconds) is
a `:timeout` error and changes nothing: call it again.

    :ok = Trellis.release_key(trellis, "order_totals", "public.orders", "42")

# `release_key!`

```elixir
@spec release_key!(trellis(), String.t(), String.t(), String.t()) :: :ok
```

Like `release_key/4`, but raises `Trellis.Error`.

# `request_backfill`

```elixir
@spec request_backfill(trellis(), String.t()) :: :ok | {:error, Trellis.Error.t()}
```

Rebuilds every definition that reads `source_table` (a bare table name,
resolved like one in a statement), and returns once that rebuild is
queued.

Each `:live` reader reports `:backfilling` (rebuilt by the call itself)
until the re-read has re-derived every row the table still has and
deleted any target row it no longer backs. A newly defined transform
doesn't need this: its backfill is queued for it. Only a table Trellis
already captures can be re-read; any other is refused.

# `request_backfill!`

```elixir
@spec request_backfill!(trellis(), String.t()) :: :ok
```

Like `request_backfill/2`, but raises `Trellis.Error`.

# `sample_quarantined`

```elixir
@spec sample_quarantined(trellis(), String.t(), [sample_option()]) ::
  {:ok, Trellis.SamplePage.t()} | {:error, Trellis.Error.t()}
```

One page of the rows quarantined under `target`, to diagnose a
quarantine's cause: for a column (`"order_totals.total"`), the rows that
failed evaluating it; for a whole transform (`"order_totals"`), the keys
poisoned from its source table.

    {:ok, page} = Trellis.sample_quarantined(trellis, "order_totals.total", limit: 50)
    {:ok, next} = Trellis.sample_quarantined(trellis, "order_totals.total", limit: 50, after: page.next_cursor)

# `sample_quarantined!`

```elixir
@spec sample_quarantined!(trellis(), String.t(), [sample_option()]) ::
  Trellis.SamplePage.t()
```

Like `sample_quarantined/3`, but raises `Trellis.Error`.

# `self_check`

```elixir
@spec self_check(trellis(), String.t(), [self_check_option()]) ::
  {:ok, Trellis.SelfCheckJob.t()} | {:error, Trellis.Error.t()}
```

Starts a background check of `target_table` against a fresh recompute of
its definition from the source tables, and returns the `Trellis.SelfCheckJob`
at once. See `t:self_check_option/0`.

The comparison is a drain worker's, a page of keys at a time (with no
drain worker anywhere in the fleet the job stays `:queued`, as a define
does). Poll `self_check_job/2` with the job's `id` until it is finished
(`Trellis.SelfCheckJob.finished?/1`); its `Trellis.SelfCheckReport` is then
the verdict:

    {:ok, job} = Trellis.self_check(trellis, "order_totals", timeout_ms: 30_000)
    {:ok, %Trellis.SelfCheckJob{state: :done, report: report}} = Trellis.self_check_job(trellis, job.id)

A target whose job is still `:queued` or `:running` gets that job back,
whatever options this call passed; a finished job stays until the next
`self_check/3` of its target replaces it. Only a one-row-per-source-key
transform can be audited; an aggregate target is a `:validation` error,
and an unknown one
`:not_found`. A column that is paused is left out of the comparison.

The call reads nothing of the target, so it returns well inside the
30-second call limit.

# `self_check!`

```elixir
@spec self_check!(trellis(), String.t(), [self_check_option()]) ::
  Trellis.SelfCheckJob.t()
```

Like `self_check/3`, but raises `Trellis.Error`.

# `self_check_job`

```elixir
@spec self_check_job(trellis(), integer()) ::
  {:ok, Trellis.SelfCheckJob.t() | nil} | {:error, Trellis.Error.t()}
```

The `Trellis.SelfCheckJob` that `self_check/3` returned with this `id`, as
it stands now: its state, how many keys it has compared so far, and, once
it is `:done`, its report. `nil` when there is none: a newer `self_check/3`
of its target replaced it, or its transform was dropped.

# `self_check_job!`

```elixir
@spec self_check_job!(trellis(), integer()) :: Trellis.SelfCheckJob.t() | nil
```

Like `self_check_job/2`, but raises `Trellis.Error`.

# `shutdown`

```elixir
@spec shutdown(trellis()) :: :ok | {:error, Trellis.Error.t()}
```

Stops the handle's background work and waits for its threads to exit. Calls
still in flight are cancelled, not waited for: their callers get a
`:validation` error, like any later call on the handle. Shutting down again
is `:ok`.

# `shutdown!`

```elixir
@spec shutdown!(trellis()) :: :ok
```

Like `shutdown/1`, but raises `Trellis.Error`.

# `start_link`

```elixir
@spec start_link([start_option()]) :: GenServer.on_start()
```

Starts a process that owns a handle: it connects with `options` (see
`t:start_option/0`) as it starts, and shuts the handle down as its
supervisor stops it. Put it in a supervision tree as `{Trellis, options}`
rather than calling this directly.

Returns `{:error, %Trellis.Error{}}` if the connect fails, so the
supervisor's start fails with the reason. A second `staging: true`
process for the same instance fails that way (`:conflict`) while the first
is alive: in a rolling deploy, stop the old one before the new one starts.

The process only keeps the handle's lifecycle. A call made through its
name asks it for the handle and then runs in the caller's own process, so
callers don't wait on each other. The caller has to be on the process's
node: a handle works only on the node that connected it, and a call from
another node returns a `:validation` error.

`shutdown/1` refuses the process's name: its supervisor stops it, which
cancels the calls still in flight (their callers get a `:validation`
error). The child spec gives it 30 seconds to shut down, since the
background threads have to exit; change it with `Supervisor.child_spec/2`.
A process killed before it shuts down leaves the handle to be shut down
when it is garbage collected.

# `status`

```elixir
@spec status(trellis(), String.t()) ::
  {:ok, Trellis.Status.t() | nil} | {:error, Trellis.Error.t()}
```

The status of the transform that writes `target_table`, or `nil` if none
does. `target_table` is either the fully qualified name `define/2` and
`apply/2` return (`"public.order_totals"`) or the bare table name
(`"order_totals"`). A qualified name with another schema matches nothing.

# `status!`

```elixir
@spec status!(trellis(), String.t()) :: Trellis.Status.t() | nil
```

Like `status/2`, but raises `Trellis.Error`.

# `watermark_token`

```elixir
@spec watermark_token(trellis()) :: {:ok, watermark()} | {:error, Trellis.Error.t()}
```

A read-your-writes token covering every write committed before this call.
Take it after a source-table write commits, then pass it to
`await_converged/3` to wait for that write to reach its targets.

# `watermark_token!`

```elixir
@spec watermark_token!(trellis()) :: watermark()
```

Like `watermark_token/1`, but raises `Trellis.Error`.

---

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