DrinkMeAlice.Tx (drink_me_alice v0.1.0)

Copy Markdown

Transaction scripts with a classifier-ready shape, and the one write path that maps transport facts plus the reply into a write outcome.

A script is BEGIN TRANSACTION; …; COMMIT TRANSACTION; RETURN {…} — every decision executes inside the database transaction, and the trailing RETURN carries a map with a decision string. build/2 records the positional context %{commit: i, decision: j, len: n} that DrinkMeAlice.Outcome needs: the pinned 3.0.5 executor pushes a COMMIT result only on success, so a failed reply is one entry shorter and a shifted RETURN must never read as commit success.

String-typed params need care on 3.0.5, which coerces JSON params that merely look typed: a <string>$p cast repairs UUID-shaped strings and record-id-shaped strings, but NOT date-shaped strings ("2026-10-01" comes back rewritten even under the cast). For an exact string at any depth, send DrinkMeAlice.exact_string/1 and read it back with DrinkMeAlice.exact_string_sql/1.

Summary

Functions

Assemble a script from its statements (without trailing ;).

Classify an already-received reply for tx (exposed for callers with their own transport).

Submit tx through a Conn owner and classify the result.

Types

outcome()

@type outcome() ::
  {:committed, map()}
  | {:existing, map()}
  | {:rejected, DrinkMeAlice.Error.t()}
  | {:unknown, t()}
  | {:unavailable, DrinkMeAlice.Error.t()}

t()

@type t() :: %DrinkMeAlice.Tx{
  ctx: %{
    commit: non_neg_integer(),
    decision: non_neg_integer(),
    len: pos_integer()
  },
  sql: binary(),
  vars: map()
}

verdicts()

@type verdicts() :: %{
  required(binary()) =>
    :committed
    | :existing
    | {:rejected, binary()}
    | {:rejected, binary(), term()}
}

Functions

build(statements, vars \\ %{})

@spec build([binary()], map()) :: {:ok, t()} | {:error, {:bad_tx, atom()}}

Assemble a script from its statements (without trailing ;).

The shape is checked, not assumed: the first statement is BEGIN TRANSACTION, exactly one COMMIT TRANSACTION sits immediately before the last statement, the last statement starts with RETURN, and the joined SQL is at most 65536 bytes. {:error, {:bad_tx, why}} otherwise.

At least TWO in-transaction statements are required (5 statements total, at most 256). With a single in-tx statement the pinned executor's commit-failure rewrite is indistinguishable from a statement abort, so every failure would classify as unknown — a script that can never be definitely rejected. Prepend a harmless LET if the work is one statement.

classify(decoded, verdicts, tx)

@spec classify(map(), verdicts(), t()) :: outcome()

Classify an already-received reply for tx (exposed for callers with their own transport).

run(conn, tx, verdicts, opts \\ [])

@spec run(GenServer.server(), t(), verdicts(), keyword()) :: outcome()

Submit tx through a Conn owner and classify the result.

  • {:committed, decision_map} / {:existing, decision_map} — per verdicts, with the whole RETURN map;
  • {:rejected, error} — a mapped rejection verdict or a qualified statement abort (definitely not committed);
  • {:unknown, tx} — the request may have committed (lost reply, inconclusive reply shape). Reconcile by resubmitting the identical idempotent script or by reading; never treat as failure;
  • {:unavailable, error} — certified not submitted (credit bound, connection down, deadline passed before send, session refused, latched clock uncertainty — gated at dispatch inside the owner), or the request was over the size bound.

The whole submission — admission, deadline and the clock gate — runs under Conn.call/3's bounded protocol; there is no separate control call, so a missing owner is a typed {:unavailable, _} and a blocked owner is bounded by the caller's own deadline.

Options: deadline_ms, class.