TypeDB for Elixir

Hex.pm Documentation License

An Elixir driver for TypeDB, built on the TypeDB HTTP API.

Requirements

TypeDB 3.12.0 or newer, Elixir 1.18+ / OTP 25+. Linux, macOS and Windows — CI compiles and runs the unit suite through all three HTTP adapters on Windows as well as Linux. The one exception is the mix typedb.check task, which shells out and therefore wants Git Bash, WSL or MSYS2 there.

Every release runs the full suite against TypeDB 3.12.0, 3.12.1 and latest, through all three HTTP adapters. 3.12.0 is the floor because it is the oldest release the suite passes on — measured by running the suite against the older releases rather than reasoned about.

On 3.11.5 the suite fails 32 tests and invalidates ten more, in four groups:

what fails tests why
every parameterised query ~19 TypeQL's given stage does not exist: given $n: string; … is [TQL0] [TQL03] … expected query_structure, wrapped as [TSV7] Query parsing failed
every read of an attribute 11 attributes arrive with no iid field, so the driver cannot decode one and answers %TypeDB.Error{kind: :decode}
User.delete/2 on an unknown user 1 400 USD3 where 3.12 answers 404
User.set_password/3 on an unknown user 1 :ok — 3.11.5 accepts setting a password for a user that does not exist, where 3.12 answers 404 USU4

The ten invalidated tests are two modules whose setup_all bulk-loads through given, so they never reach a test body. One more failure comes and goes between runs — a transaction that times out opening under the soak's concurrency — and it is load rather than a version difference, so the stable count is 32.

The first two groups are the decisive ones, and neither is a patch: given is how this driver makes a parameterised query safe, and an attribute with no iid cannot be turned into a TypeDB.Concept at all. Older still is worse, not better: on 3.5.0 /v1/servers does not exist and inserting then matching fails outright. If you need a release older than 3.12.0, open an issue — it would mean giving up given, so it is a decision rather than a patch.

Installation

def deps do
[
{:typedb, "~> 0.11.0"},
# The default transport. Leave it out only if you select TypeDB.HTTP.Httpc.
{:finch, "~> 0.23"}
]
end

Requires Elixir 1.18+ and OTP 25+. Developed and tested on Linux; the driver is pure Elixir, so it runs on anything the BEAM does.

Versioned per SemVer. While in 0.x, a minor bump may change the public API and a patch will not — see CONTRIBUTING for exactly what counts as breaking.

telemetry is the only hard dependency — one small application, no dependencies of its own. Every transport's dependency is optional, so your footprint follows the transport you pick:

You select You add Pulls in
TypeDB.HTTP.Finch (default) {:finch, "~> 0.23"} finch, mint, hpax, mime, nimble_options, nimble_pool
TypeDB.HTTP.Req {:req, "~> 0.7"} req and its own dependencies
TypeDB.HTTP.Httpc nothing nothing — it is OTP's own client

If you forget, TypeDB.start_link/1 says so by name rather than failing mysteriously later. {:decimal, "~> 2.4 or ~> 3.0"} is optional too: TypeDB's decimal values decode to Decimal.t() when it is loaded and stay strings otherwise. JSON is handled by Elixir's built-in JSON module and needs no dependency; to route it through a different codec, configure one:

config :typedb, :json_codec, TypeDB.JSON.Jason

Quick start

Start a TypeDB server:

typedb server # or, from a clone of this repository: docker compose up -d

Add a connection to your supervision tree:

children = [
{TypeDB,
url: "http://localhost:8000",
username: "admin",
password: System.fetch_env!("TYPEDB_PASSWORD")}
]
Supervisor.start_link(children, strategy: :one_for_one)

start_link/1 validates the options and starts the process; it does not contact the server. A wrong password or an unreachable host starts cleanly and fails on the first request instead, which is deliberate — the driver signs in lazily, so your application boots whether or not TypeDB is up yet. If you would rather find out at boot, call TypeDB.Server.health/1 yourself once the supervisor is running.

Run several connections by giving each a name, which is also how you address it:

children = [
{TypeDB, name: :analytics, url: "http://analytics:8000", username: "admin", password: pw},
{TypeDB, name: :ingest, url: "http://ingest:8000", username: "admin", password: pw}
]
TypeDB.query(:analytics, "events", "match $e isa event;", transaction_type: :read)

Define a schema, insert some data, read it back. conn below is a connection name — TypeDB is the default one:

conn = TypeDB
TypeDB.create_database(conn, "social")
TypeDB.query!(conn, "social", """
define
attribute name, value string;
attribute age, value integer;
entity person, owns name, owns age;
""")
TypeDB.query!(conn, "social", """
insert
$alice isa person, has name "Alice", has age 30;
$bob isa person, has name "Bob", has age 41;
""", transaction_type: :write)
TypeDB.query!(conn, "social", """
match $p isa person, has name $name;
select $name;
""", transaction_type: :read)
|> Enum.map(&TypeDB.ConceptRow.value(&1, "name"))
#=> ["Alice", "Bob"]

Queries

One-shot

TypeDB.query/4 runs a query in a transaction of its own — TypeDB opens it, runs the query, and commits or closes it, all in a single round trip.

{:ok, answer} = TypeDB.query(conn, "social", "match $p isa person;", transaction_type: :read)

Reads never commit. Writes and schema changes commit by default; pass commit: false for a dry run.

:transaction_type defaults to :schema, because that is the only type that accepts every kind of query — including define. It is also the type that takes an exclusive, database-wide lock (see the table below), so pass :read or :write explicitly for anything that is not a schema change.

That lock is not theoretical. Hold a :schema transaction open for a second, and a one-shot query left on the default waits the whole second for it, while the same query with transaction_type: :read returns in a millisecond. An integration test pins both halves. Left on the default, your queries queue behind each other and behind every schema change in the system.

Reading more than fits

TypeDB caps a read at 10,000 answers. TypeDB.stream/4 walks past that, one page at a time, as a lazy Stream of rows:

conn
|> TypeDB.stream("social", """
match $p isa person, has name $n;
sort $n;
select $n;
""")
|> Stream.map(&TypeDB.ConceptRow.typed_value(&1, "n"))
|> Enum.each(&IO.puts/1)

Measured against 3.12.1 over 25,000 rows: query/4 returns 10,000 of them with TypeDB.Answer.truncated?/1 true, and stream/4 returns all 25,000 in 998 ms. The whole walk runs in one :read transaction, so it reads one snapshot rather than several — a walk that collected 25,000 rows saw none of the 5,000 inserted concurrently while it ran.

Three things to know before you use it:

It raises instead of returning {:error, _}, because a lazy stream has nowhere to put an error tuple: the failure happens inside whatever consumes it.

Multi-statement transactions

TypeDB.transaction(conn, "social", :write, fn tx ->
TypeDB.Transaction.query!(tx, ~s(insert $p isa person, has name "Alice";))
TypeDB.Transaction.query!(tx, ~s(insert $p isa person, has name "Bob";))
:ok
end)

The block commits on success, and rolls back if it returns {:error, _}, raises, throws or exits. A :read block is closed rather than committed.

The commit itself can fail after your block succeeded — because a concurrent :write transaction touched the same data and committed first, which TypeDB answers with 400 STC2 — and that surfaces as {:error, %TypeDB.Error{}} from transaction/5. TypeDB.Error.retryable?/1 is true for it; re-run the whole block to survive it. :max_retries does not cover this, as it retries transport failures on requests that never reached the server.

For a commit point that isn't lexically scoped, drive it yourself with TypeDB.Transaction.open/4, commit/1, rollback/1 and close/1.

Parameterised queries

Never interpolate user input into a query string. TypeQL injection is as real as SQL injection, and TypeDB 3.12's given stage is the fix: values travel beside the query rather than inside it, so they can never be parsed as TypeQL.

TypeDB.query(conn, "social", """
given $n: string;
insert $p isa person, has name == $n;
""", given_rows: [%{"n" => "Alice"}, %{"n" => "Bob"}])

The pipeline runs once per row, so this is also the fast way to write many rows: one request and one query compilation instead of N. Measured by bench/given.exs, writing 2,000 rows three ways against a local server:

how
one request per row 8830ms 226 rows/s
one request, 2,000 insert statements in the query 1541ms 1,298 rows/s
one request, 2,000 given rows 101ms 19,647 rows/s

The second row is the honest competitor — it is also a single request — so the 15× between it and given is query compilation and string building, and the gap widens with the row count because only one of them compiles once.

Declare every input variable in the given stage, marking optional columns with ?:

given $name: string, $age: integer?;

Pass plain Elixir terms — strings, numbers, booleans, Date, NaiveDateTime, DateTime, TypeDB.DateTimeTZ, TypeDB.Duration, Decimal, nil for an unbound optional column — or concepts from an earlier answer, which is how you bind a given $p: person; column:

TypeDB.query(conn, "social", """
given $p: person;
match $p has name $n;
select $n;
""", given_rows: [%{"p" => row["p"]}], transaction_type: :read)

The driver encodes these into TypeDB's tagged wire form. That matters: the HTTP API also accepts a bare JSON string, but TypeDB parses that as a TypeQL literal, so a value containing a quote is a parse error. Going through given_rows is exact for any input — quotes, semicolons, newlines — which is what makes it injection-safe. See TypeDB.Given.

Transaction types

Type Use for Notes
:read queries Concurrent, never commits
:write data changes Concurrent; conflicting commits fail at commit time
:schema schema changes Takes an exclusive, database-wide lock

Answers

Every query returns one of three shapes:

case TypeDB.query!(conn, "social", query) do
%TypeDB.Answer.ConceptRows{rows: rows} -> rows # match / insert / update
%TypeDB.Answer.ConceptDocuments{documents: docs} -> docs # fetch
%TypeDB.Answer.Ok{} -> :ok # define / undefine
end

ConceptRows and ConceptDocuments are Enumerable, so they pipe straight into Enum and Stream.

Rows implement Access:

row["person"] #=> %TypeDB.Concept.Entity{iid: "0x1e0...", type: %TypeDB.Concept.EntityType{label: "person"}}
row["name"] #=> %TypeDB.Concept.Attribute{value: "Alice", value_type: "string"}
TypeDB.ConceptRow.value(row, "name") #=> "Alice"
TypeDB.ConceptRow.typed_value(row, "born") #=> ~D[1994-03-01]

A whole row at once, either as TypeDB sent it or converted:

TypeDB.ConceptRow.to_map(row)
#=> %{"name" => "Alice", "born" => "1994-03-01", "worked" => "P1Y2M3DT4H5M6S"}
TypeDB.ConceptRow.to_typed_map(row)
#=> %{"name" => "Alice", "born" => ~D[1994-03-01], "worked" => %TypeDB.Duration{months: 14, ...}}
TypeDB.ConceptRow.to_struct(row, Person, typed: true)
#=> %Person{name: "Alice", born: ~D[1994-03-01]}

Values

Values arrive exactly as the HTTP API defines them: JSON primitives for boolean, integer, double and string, and strings for decimal, date, datetime, datetime-tz and duration. TypeDB.Concept.typed_value/1 converts:

TypeDB Elixir
boolean, integer, double, string boolean, integer, float, String.t()
date Date.t()
datetime NaiveDateTime.t()
datetime-tz TypeDB.DateTimeTZ.t()
duration TypeDB.Duration.t()
decimal Decimal.t() if :decimal is loaded, otherwise the string

TypeDB.DateTimeTZ and TypeDB.Duration keep the original wire string, so TypeDB's nanosecond precision survives conversion to Elixir's coarser types.

Query options

TypeDB.query(conn, "social", "match $p isa person;",
transaction_type: :read,
include_instance_types: false, # skip a type lookup per concept on hot paths
include_query_structure: false, # on by default; ~740 bytes per response, whatever the row count
answer_count_limit: 1_000, # the HTTP API is not streaming — cap the result set
timeout: 120_000 # for long analytical reads
)

include_query_structure is on by TypeDB's default, so every ConceptRows answer carries the analysed pipeline in :query_structure whether or not you read it. Measured against TypeDB 3.12.1, a one-row answer is 1069 bytes with it and 326 without — a fixed cost per response, not per row. Turn it off on hot paths; leave it on if you are building query tooling. The field is raw decoded JSON, passed through exactly as TypeDB sent it, and its shape is TypeDB's to change.

answer_count_limit raises a cap as well as lowering one. TypeDB truncates a read at 10,000 answers by default. That is not a driver default and there is no server flag for it: the request option is the only control. Measured against 3.12.1 — a match over 20,000 entities returns exactly 10,000 rows, while reduce $n = count over the same data says 20,000.

Truncation is not an error. The answer arrives with the rows it did produce, so code that counts what came back is quietly wrong — ask instead:

{:ok, answer} = TypeDB.query(conn, "social", "match $p isa person;", transaction_type: :read)
if TypeDB.Answer.truncated?(answer) do
{:error, :partial} # page it, raise the cap, or aggregate server-side
else
{:ok, TypeDB.Answer.rows(answer)}
end

The row count cannot answer this, in either direction. TypeDB warns only when it really had more, so a complete answer can be exactly the size of the cap; and a query carrying its own limit can come back smaller than the cap and still be complete. TypeDB.Answer.warning/1 gives you the server's text, which names the limit that was hit — read it, log it, but branch on truncated?/1.

The driver logs the warning at :warning level so a truncated answer is never silent, but the decision is yours: raise the limit, page the query yourself with offset/limit in TypeQL, or aggregate server-side with reduce.

Set it once per connection to apply to every query that does not override it:

{TypeDB, url: "...", username: "...", password: "...", answer_count_limit: 50_000}

Telemetry

Every request and sign-in is a :telemetry span:

:telemetry.attach("typedb", [:typedb, :request, :stop], fn _event, %{duration: d}, meta, _ ->
Logger.info("typedb #{meta.method} #{meta.path} -> #{meta[:status]} in #{d}")
end, nil)

See TypeDB.Telemetry for the full event list and metadata.

Administration

TypeDB.Database.list(conn)
TypeDB.Database.create(conn, "social") # a no-op if it already exists
TypeDB.Database.schema(conn, "social") # TypeQL source, round-trips through `define`
TypeDB.Database.delete(conn, "social") # irreversible
TypeDB.User.list(conn)
TypeDB.User.create(conn, "alice", password) # 400 USC2 if alice already exists
TypeDB.User.set_password(conn, "alice", new_password)
TypeDB.User.delete(conn, "alice")
TypeDB.Database.get(conn, "social") # 404 if it does not exist
TypeDB.Database.exists?(conn, "social")
TypeDB.Database.create_if_not_exists(conn, "social")
TypeDB.Database.type_schema(conn, "social") # types only, without functions
TypeDB.User.get(conn, "alice")
TypeDB.User.exists?(conn, "alice")
TypeDB.Server.health(conn, timeout: 500) # unauthenticated readiness probe
TypeDB.Server.version(conn)
TypeDB.Server.servers(conn) # cluster membership

Note the asymmetry, which is TypeDB's own: creating a database that already exists succeeds, creating a user that already exists does not. TypeDB.Database.create_if_not_exists/2 states that intent explicitly for databases and also copes with a server that rejects the duplicate; for users, check TypeDB.User.exists?/2 first.

Errors

case TypeDB.query(conn, "social", query) do
{:ok, answer} ->
answer
{:error, %TypeDB.Error{kind: :server, code: code}} ->
Logger.error("TypeDB rejected the query: #{code}")
{:error, %TypeDB.Error{kind: :transport}} ->
:retry_later
end

:kind is one of :server, :transport, :timeout, :unauthenticated, :decode, :encode or :config. For :server errors, :code holds TypeDB's own error code ("TSV11", "AUT3", …), which is stable across releases — branch on that, not on messages.

Every function that can fail also has a ! form that returns the value and raises TypeDB.Error instead — TypeDB.Database.list!/1, TypeDB.User.create!/3, TypeDB.Transaction.commit!/1 and so on. The one exception is TypeDB.transaction/5, which returns whatever your block returned — except that a :write or :schema block that succeeded but whose commit failed returns {:error, %TypeDB.Error{}}. Because that error can be indistinguishable from one your own block returned, there is no ! form to decide what to raise.

Configuration

Option Default
:url "http://localhost:8000" "host:port" is accepted and assumes http
:username / :password — required unless :token is given
:token — pre-issued bearer token; cannot be renewed on expiry
:name TypeDB registered name; start several connections under different names
:timeout 60_000 per-request receive timeout, ms
:connect_timeout 10_000 TCP/TLS connect timeout, ms
:http {TypeDB.HTTP.Finch, []} adapter and its options
:max_retries 1 transport-failure retries for idempotent requests
:max_auth_renewals 2 token renewals a single request may make after a 401
:answer_count_limit — default cap on answers per query; see below
:retry_backoff {:exponential, 100} or a (attempt -> ms) function

:connect_timeout bounds connecting, under every adapter, and a host that never answers fails with kind: :timeout — not :transport — whichever adapter is in use. Waiting for a free connection when the pool is saturated is a different thing with a different knob: http: {TypeDB.HTTP.Finch, pool_timeout: …}.

TLS

Certificate verification is on by default under every adapter — peer verification, the OS trust store and hostname checking — and turning it off takes an explicit option. To pin a private CA:

# Finch (default)
http: {TypeDB.HTTP.Finch, conn_opts: [transport_opts: [cacertfile: "/etc/ssl/private-ca.pem"]]}
# httpc
http: {TypeDB.HTTP.Httpc, cacertfile: "/etc/ssl/private-ca.pem"}

Choosing a transport

The default is TypeDB.HTTP.Finch, which starts a Finch pool per connection:

{TypeDB, url: "...", username: "...", password: "...",
http: {TypeDB.HTTP.Finch, size: 100, count: 2}}

Two alternatives ship with the driver:

# Reuse a Finch your app already runs through Req. Needs {:req, "~> 0.7"}.
http: {TypeDB.HTTP.Req, finch: MyApp.Finch}
# OTP's own client, needing nothing at runtime — see below for what that costs.
http: {TypeDB.HTTP.Httpc, max_sessions: 100}

Measured by bench/transport.exs against a local TypeDB, 400 requests per run with a warm pool:

Concurrency :httpc Req Finch
16 569 req/s, p50 28ms 1656 req/s, p50 9ms 2052 req/s, p50 7ms
64 437 req/s, p50 146ms 1628 req/s, p50 38ms 1830 req/s, p50 32ms
200 408 req/s, p50 336ms 1675 req/s, p50 101ms 1840 req/s, p50 100ms

:httpc does not scale with concurrency: three to four times slower throughout, with a p99 that reaches 553ms where Finch's is 112ms. Pick it deliberately, not by default.

About the numbers on this page

Every figure here about speed or throughput comes from a script in bench/ that you can run, and each script prints the machine and the versions it ran on before its first number — bench/README.md maps each claim to the script that produces it. The ratio is the finding; the absolute number is a property of the machine. The table above was produced on the maintainer's container; re-run on another, the same three-to-four-fold gap appears with different absolute figures.

That distinction is not pedantry. 0.1.0 published 77 req/s for :httpc at 200-way; it did not reproduce, and a figure that survives after being corrected elsewhere reads as a measurement when it is not.

Numbers about TypeDB's own behaviour rather than this driver's speed — the 300,000 ms transaction lifetime, the 10,000-answer cap, the request-body limit — are not benchmarks. They are pinned by the integration suite, which re-runs them on every push against three TypeDB versions.

Any module implementing the TypeDB.HTTP behaviour works.

Validating TypeQL

Install typeql-check and lint your .tql files without a server:

mix typedb.check # checks priv/**/*.tql
mix typedb.check "test/**/*.tql"

It exits non-zero on a parse error, so it drops straight into CI. It checks syntax only — for schema-aware validation, run TypeDB.Transaction.analyze/3 against a real database.

This is the one part of the project that needs a POSIX shell: each file goes to typeql-check on stdin, since a schema past about a megabyte cannot be passed as a command-line argument. On Windows, run it from Git Bash, WSL or MSYS2. The driver itself is pure Elixir and runs anywhere the BEAM does.

If you use an AI coding agent, TypeDB's configure-coding-agent guide pairs typeql-check with the typedb-skills TypeQL skill file.

Guides

Run in Livebook

The notebook is the fastest way to find out whether this driver fits: a database, a schema, reads and writes, a parameterised query that survives a hostile value, and a transaction — against a TypeDB you start with one docker run.

Then six guides, for the things a reference page cannot teach:

Where your credentials live

Worth stating plainly, since the driver is the thing holding them.

The password, and a pre-issued :token, are kept in the connection process and nowhere else. They are stripped from the copy published to the connection's ETS table, hidden by TypeDB.Config's Inspect, and therefore absent from crash reports, :sys.get_state/1, :observer, log lines and telemetry metadata. test/typedb/security_test.exs asserts each of those, and a :passphrase on a TLS key gets the same treatment.

The bearer token TypeDB issues is a different matter: it lives in the connection's ETS table, which is :protected — every process on the node can read it. That is not an oversight, it is the design. Requests run in the calling process, which means the calling process has to be able to read the token, which means it cannot be a secret from the rest of the VM. Any process that can read it could have called TypeDB.query/4 anyway, so nothing is gained by hiding it; what matters is that it does not escape the node, and it does not — no log line or telemetry event carries it.

Limitations

None of these are bugs. All of them are surprises if you meet them for the first time in production.

One answer arrives whole. The HTTP API does not stream, so an answer is materialised on the server, shipped, and decoded into one term in your process. bench/answer_size.exs measures the cost: for rows of two attributes, 488 bytes per row on the wire and 897 bytes decoded — so a million rows in one answer would be about 0.8 GiB of term memory, in one process, at once.

TypeDB.stream/4 is the way out of that and of the cap below: it pages the answer inside one transaction, so what is materialised at once is a page rather than the whole result. It is paging, not streaming — the transport still ships one complete response per request — and it costs a round trip per page and a sort you have to write yourself. See Reading more than fits.

TypeDB caps a read at 10,000 answers by default rather than letting you find that out, and attaches a warning saying so. Raise the cap with :answer_count_limit when you need more, and treat an unbounded match the way you would treat SELECT * without a LIMIT — see Query options.

A connection points at one server. TypeDB.Server.servers/1 will tell you what the cluster looks like, and nothing in the driver acts on it: there is no failover, no read-replica routing and no reconnection to a different node. Put a load balancer in front of a cluster, or supervise one connection per node and choose between them yourself.

This is a decision rather than an omission, and it has now been taken deliberately for 1.0 — out, with the reasoning written down in docs/cluster-1.0-decision.md. The short version is that the cluster protocol is not in the API this driver speaks: TypeDB's own documentation gives replica discovery, primary routing and failover to the gRPC drivers, and says the HTTP endpoint is "unchanged and can be used the usual way by explicitly choosing a specific replica to send requests to". Clustering in 3.x is also alpha and Cloud/Enterprise-only, so CI has nothing to test failover against — and on CE, TypeDB.Server.servers/1 returns one entry whose address is nil, which is to say there is nothing to route to even if the driver wanted to.

Accepting :urls later is additive and needs no 1.0 slot held open for it. If you want cluster-aware behaviour today, it belongs in the sibling typedb_grpc package, which speaks the protocol that has it.

Retries block the caller. Requests run in the calling process, which is what makes the driver concurrent — but it also means a retry and its backoff are spent in your process, not in a queue behind a connection. See "How long a call can take" in the TypeDB docs for the arithmetic, and set :deadline if a call must not outlive a budget.

One HTTP pool per connection. TypeDB.HTTP.Finch starts a pool the connection owns. Several connections to the same server are several pools; they do not share sockets. That is usually what you want and occasionally is not.

mix typedb.check needs a POSIX shell. It is the only part of the project that shells out, and on Windows it wants Git Bash, WSL or MSYS2. The driver itself is pure Elixir and CI proves it on Windows.

A walk has a deadline, and it belongs to the transaction. TypeDB.stream/4 holds one transaction open for the whole walk, and TypeDB bounds a transaction by transaction_timeout_millis — 300,000 ms by default, counted from the moment it opens and not reset by requests. It is a lifetime, not an idle timer. A slow consumer therefore loses the walk mid-flight with TSV12, raised from inside whatever is consuming the stream; measured at test/integration/transaction_lifetime_integration_test.exs, where 3,000 rows read at 2 ms per row died at 4,282 ms against a 4,000 ms budget. Pass :transaction_timeout_millis, and pass it generously — a retry does not resume, it restarts from the first page against a snapshot that is gone.

fetch pipelines cannot be paged. TypeDB.stream/4 appends offset and limit, and those after a fetch stage are a syntax error — measured, 400 TQL0. So streaming works for conceptRows and nothing else. Read the same data with match … select if you need to page it.

A connection's name must be a plain atom. :via and :global tuples are refused with an error saying so, because the connection publishes its config to a named ETS table — that table is what lets requests run in the caller's process instead of through the connection, which is the driver's central design decision. So a connection cannot live in a Registry or be distributed with :global. Name each connection and address it by that atom.

Two optional dependencies change the type of your data, not just the footprint. Without Decimal, a TypeDB decimal reaches you as a string — TypeDB.Concept.typed_value/1 strips TypeQL's dec suffix either way, so the content matches and the type does not. Add {:decimal, "~> 2.4"} if your schema has decimals and your code expects to do arithmetic on them. The same shape of surprise applies to :finch: leave it out and the default adapter tells you at start-up rather than at the first query.

Temporal values TypeDB cannot store are refused when you encode them, not when you send them. A negative TypeDB.Duration raises TypeDB.Error with kind :encode — TypeQL has no negative duration in any form. So does a fixed UTC offset that is not a whole number of minutes, or one at or beyond a day: TypeDB's datetime-tz literal takes -23:59 to +23:59 and rejects +00:01:15 outright, measured at test/integration/datetime_tz_offset_integration_test.exs. That last one bites where you would not expect it — a time zone database hands out a seconds-bearing offset for any pre-1900 timestamp — and the fix is to pass the IANA zone name, which TypeDB stores exactly. Related: a datetime-tz read back from TypeDB keeps its nanoseconds in :raw and only microseconds in :naive, because NaiveDateTime has nowhere to put the other three digits.

TypeDB 3.12.0 or newer, and that is a measured floor rather than a cautious one: 3.11.5 has no given stage. See Requirements.

Transaction.analyze/3 returns TypeDB's own map, from an endpoint that is not in the published HTTP API reference. It is the one return value this driver's SemVer does not cover.

Database import and export are not here, and neither is anything else the HTTP API does not expose — those are gRPC-only in TypeDB 3.x. Everything the API does expose is covered: sign-in and token renewal, databases, users, servers, version and health, transactions, one-shot queries, and query analysis.

Development

mix deps.get
mix test # unit tests, no server required
docker compose up -d # TypeDB 3.12.1 on :8000, from a clone
TYPEDB_INTEGRATION_URL=http://localhost:8000 mix test --include integration

The unit suite runs the whole driver against TypeDB.Stub, a real HTTP/1.1 server that speaks the TypeDB API — so transport, encoding and error mapping are exercised without a database. The integration suite runs the same paths against a real TypeDB server.

Three further opt-in suites cover things an ordinary run never reaches. They live in the repository rather than in the published package, and each module's doc carries the exact command to stand up the server it needs:

TYPEDB_SLOW_TESTS=1 additionally runs the tests that wait out real timeouts, and TYPEDB_TEST_ADAPTER=finch|req|httpc runs the whole suite through one transport. CI runs all of them.

Support

Report a bug or a missing capability as a GitHub issue; ask a question in Discussions. A good report names the driver version, the TypeDB version, the HTTP adapter, and what the server answered — %TypeDB.Error{} carries the :kind, TypeDB's :code and the HTTP :status, and pasting the struct usually says more than the prose around it.

This is maintained by one person, in the open, under Apache-2.0, with no service-level promise. What is promised is the version number: see Versioning for what a release can and cannot change under it, and the release runbook for how one is cut.

Security issues: please report them privately through the repository's Security tab rather than as a public issue.

License

Apache-2.0. See LICENSE.

TypeDB is a trademark of TypeDB Ltd. This driver is not an official TypeDB product.