tricoredb 0.3.0

Blocking Rust client for TriCoreDB's native `tricore` protocol: SQL, documents, cache, vectors, graphs and LLM context over one connection.
Documentation

tricoredb

Official Rust client for TriCoreDB: SQL, documents, vectors, graphs and cache over one native connection.

crates.io docs.rs license

  • Blocking, no async runtime. Calls block; a Pool serves concurrent work.
  • Server-side parameters. Values never become part of the SQL text.
  • Typed errors you branch on by kind and code, never by message.
  • Transactions, connection pooling, TLS and mutual TLS.
  • Four dependencies, two of them optional: serde, serde_json, and rustls only when TLS is on.

Contents

Requirements

  • Rust 1.85 or later, which is what rustls and its dependencies need.
  • A TriCoreDB server speaking protocol 1.0 (tricore-server 0.1.0-rc.1 or later). See Running a server.
  • The methods added in 0.3.0 — marked below — need tricore-server 0.1.0-rc.3 or later. An older server does not know those operations and answers them with an error; everything else works against it as before.

Installation

cargo add tricoredb

Or in Cargo.toml:

[dependencies]
tricoredb = "0.3"

TLS is on by default. For a build with no cryptography dependencies at all:

[dependencies]
tricoredb = { version = "0.3", default-features = false }

Running a server

The quickest way is the official Docker image, trinesh14/tricoredb.

Local development (no TLS and no encryption, for this machine only). Set TRICORE_ADMIN_PASSWORD in your shell first. Then create the admin and start the server:

docker run --rm -v tricoredb-dev:/var/lib/tricoredb -e TRICORE_ADMIN_PASSWORD --entrypoint /usr/local/bin/tricore trinesh14/tricoredb:0.1.0-rc.1-r2 auth init-admin --user admin --password-env TRICORE_ADMIN_PASSWORD --data-dir /var/lib/tricoredb/data
docker run -d --name tricoredb-dev -p 127.0.0.1:8427:8427 -e TRICORE_TLS=off -e TRICORE_ENCRYPTION=off -e TRICORE_MODULES=all -v tricoredb-dev:/var/lib/tricoredb trinesh14/tricoredb:0.1.0-rc.1-r2

Anything else: by default the image runs with TLS on and an encrypted data volume. Follow the quick start on the Docker Hub page to create the certificate and key, then connect with TLS.

TRICORE_MODULES=all enables every data model. The image's default is sql, document and cache. A call to a disabled model fails with an error whose code() is engine.disabled.

Quick start

use tricoredb::{params, Client, Options};

fn main() -> tricoredb::Result<()> {
    let mut db = Client::connect(&Options::new("127.0.0.1", 8427).user("admin").secret("your-password"))?;

    db.execute("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, name TEXT)")?;
    db.execute_params("INSERT INTO users VALUES (?, ?)", &params![1, "O'Hara"])?;

    let rows = db.query_params("SELECT name FROM users WHERE id = ?", &params![1])?;
    println!("{:?}", rows.get(0, "name")); // Some("O'Hara")

    db.cache_set("sessions", "u1", b"token")?;
    if let Some(token) = db.cache_get("sessions", "u1")? {
        println!("{} bytes", token.len());
    }
    Ok(())
}

Connecting

Client::connect opens one authenticated connection. Dropping it ends the session; close() says goodbye first and reports whether the socket closed cleanly.

Options field Default Meaning
host "127.0.0.1" Server host
port 8427 (tricoredb::DEFAULT_PORT) Server port
user None Principal to authenticate as
secret None Password or token
database "main" Database named in every request
client_name "tricoredb-rust/<version>" Name reported in the handshake
connect_timeout None Bound on the connect and TLS handshake
read_timeout None Bound on each wait for a reply
tls None (plain TCP) See TLS
features all Capability bitmap announced in the handshake
follow_leader true Send a not_leader refusal again, to the leader
leader_retries 5 How many times a refused request is sent again
leader_retry_delay 250ms First pause before asking again; doubles up to 2 s

Each field has a builder method, so options read as one expression:

use std::time::Duration;
use tricoredb::Options;

let options = Options::new("db.internal", 8427)
    .user("admin")
    .secret("your-password")
    .database("reporting")
    .connect_timeout(Duration::from_secs(5));

One connection runs one request at a time. Every method takes &mut self, so sharing a Client between threads does not compile — the frame interleaving that would corrupt a connection is ruled out rather than documented. Use a pool for concurrency.

SQL

query runs only SELECT. execute runs everything else. The server enforces the split: a write sent through query is refused.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::{params, Param};

db.execute("INSERT INTO users VALUES (2, 'ada')")?;
db.execute_params(
    "INSERT INTO accounts VALUES (?, ?)",
    &params![1, Param::decimal("10.50").unwrap()],
)?;

let rows = db.query("SELECT id, name FROM users")?;
for row in &rows.rows {
    println!("{row:?}");
}
println!("{:?}", rows.get(0, "name"));
# Ok(()) }

execute_params and query_params bind ? placeholders on the server. The values travel next to the statement, so a value can never be read as SQL syntax, however it is spelled. The params! macro builds the list from mixed types:

Rust value Sent as
None SQL NULL
bool a boolean
any integer, up to i128 / u128 an exact number, never through f64
f32 / f64 a number; NaN and infinities are refused
Param::decimal("10.50") exact digits, no exponent — for DECIMAL
Vec<u8> / &[u8] 0x-prefixed hex, for BLOB
&str / String text

Binding needs the SERVER_PARAMS capability, agreed in the handshake (db.server_params_granted()). If the server did not grant it, these calls fail with ErrorKind::FeatureNotGranted before anything is sent. They never fall back to pasting values into the statement text: escaping and binding are not the same guarantee.

Transactions

Two kinds, and they are not interchangeable.

transaction sends a whole BEGIN … COMMIT script in one request. It works on every node, and is right when every statement is known up front:

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::{params, Statement};

let result = db.transaction(&[
    Statement::with_params("UPDATE accounts SET balance = balance - ? WHERE id = ?", params![10, 1]),
    Statement::with_params("UPDATE accounts SET balance = balance + ? WHERE id = ?", params![10, 2]),
])?;
assert_eq!(result.outcome, "committed");
# Ok(()) }

begin, commit and rollback keep a transaction open across requests on this connection, so a later statement can depend on what an earlier one read. with_transaction commits when the closure returns Ok and rolls back on Err:

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::params;

db.with_transaction(|tx| {
    tx.execute_params("UPDATE accounts SET balance = balance - ? WHERE id = ?", &params![10, 1])?;
    tx.execute_params("UPDATE accounts SET balance = balance + ? WHERE id = ?", &params![10, 2])?;
    Ok(())
})?;
# Ok(()) }

Session transactions need the SESSION_TXN capability (db.session_txn_granted()). Without it, begin fails by name rather than silently running each statement on its own. The transaction belongs to this connection: another connection cannot commit it, and a dropped socket rolls it back.

Connection pool

A Pool is built from the same Options, so every pooled connection uses the same TLS settings. It is Clone and Send, so threads share one pool.

# fn main() -> tricoredb::Result<()> {
use tricoredb::{params, Options, Pool};

let pool = Pool::new(Options::new("db.internal", 8427).user("admin").secret("pw"), 8)?;

std::thread::scope(|scope| {
    for id in 1..=8 {
        let pool = pool.clone();
        scope.spawn(move || {
            pool.with_connection(|db| {
                db.execute_params("INSERT INTO users VALUES (?, ?)", &params![id, "grace"])?;
                Ok(())
            })
        });
    }
});

pool.close();
# Ok(()) }

with_connection lends a connection for the duration of a closure rather than handing back a guard, because a guard is something a caller can hold across threads. A connection is never returned to the pool with a transaction still open: it is rolled back first, and retired if that rollback fails.

Cache

Values are bytes. None is a miss, which is how a miss is told apart from a stored empty value.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use std::time::Duration;

db.cache_set("sessions", "u1", b"token")?;
db.cache_set_ttl("sessions", "u2", b"token", Some(Duration::from_secs(30)))?;
let value = db.cache_get("sessions", "u1")?;

db.cache_incr("counters", "hits", 1)?;
db.cache_rpush("queue", "jobs", &[b"a", b"b"])?;
db.cache_sadd("tags", "post:1", &[b"rust", b"db"])?;
db.cache_hset_text("user:1", "profile", [("name", "ada")])?;
let id = db.cache_xadd_text("events", "log", [("msg", "hi")], None)?;
# let _ = (value, id); Ok(()) }
Family Methods
Keys cache_get, cache_set, cache_set_ttl, cache_set_nx, cache_delete, cache_exists, cache_ttl, cache_expire, cache_persist, cache_incr, cache_keys, cache_clear_namespace, cache_ping
Lists cache_lpush, cache_rpush, cache_lpop, cache_rpop, cache_lrange, cache_llen, cache_lindex
Sets cache_sadd, cache_srem, cache_sismember, cache_scard, cache_smembers
Hashes cache_hset, cache_hset_text, cache_hget, cache_hdel, cache_hgetall, cache_hexists, cache_hlen
Streams cache_xadd, cache_xadd_text, cache_xlen, cache_xrange, cache_xread, cache_xdel, cache_xtrim

Added in 0.3.0, for server 0.1.0-rc.3 and later:

Family Methods
Many keys, conditions cache_mget, cache_mset, cache_warm, cache_set_if, cache_compare_and_swap, cache_get_del, cache_get_set, cache_get_ex, cache_append, cache_expire_at
Stale-while-revalidate cache_set_fresh, cache_get_fresh, cache_set_missing
Sorted sets cache_zadd, cache_zrem, cache_zscore, cache_zcard, cache_zrank, cache_zrange, cache_zrange_with_scores, cache_zrange_by_score, cache_zrange_by_score_with_scores, cache_zincr_by
Locks cache_lock_acquire, cache_lock_renew, cache_lock_release
Bitmaps cache_set_bit, cache_get_bit, cache_bit_count, cache_bit_pos, cache_bit_op
HyperLogLog cache_pf_add, cache_pf_count, cache_pf_merge
Lists, sets, hashes cache_lmove, cache_ltrim, cache_blpop, cache_brpop, cache_sunion, cache_sinter, cache_sdiff, cache_srand_member, cache_hincr_by
Pipelines and scripts cache_pipeline, cache_transaction, cache_script_load, cache_script_run
Publish and poll cache_publish, cache_poll
Node and database cache_stats, cache_hot_keys, cache_purge_expired, cache_flush_database, cache_set_namespace_limit, cache_get_namespace_limit
# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use std::time::Duration;
use tricoredb::{CacheCommand, ScoreBound, ScoredMember};

db.cache_zadd("game", "board", &[ScoredMember::new(120.0, "ada")])?;
let top = db.cache_zrange_with_scores("game", "board", 0, 9, true)?;
let above = db.cache_zrange_by_score(
    "game", "board", ScoreBound::exclusive(100.0), ScoreBound::highest(), false, None,
)?;

let lock = db.cache_lock_acquire("jobs", "nightly", "worker-1", Duration::from_secs(30))?;
if lock.acquired {
    // Pass `lock.token` to whatever the lock protects.
    db.cache_lock_release("jobs", "nightly", "worker-1")?;
}

let replies = db.cache_pipeline(&[
    CacheCommand::set("sessions", "u1", b"token", None),
    CacheCommand::get("sessions", "u1"),
])?;
# let _ = (top, above, replies); Ok(()) }

A sorted-set score is always finite, and JSON has no spelling for an infinity, so an open range end is ScoreBound::lowest() or ScoreBound::highest(); a non-finite score or bound is refused before anything is sent. cache_hot_keys answers only when the server's [cache] hot_keys setting is above zero, and returns the payload as the server sent it. cache_flush_database deletes every cache key in the connection's database.

Documents

Filters and pipeline stages are built with constructors, so you never write the request JSON by hand.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use serde_json::json;
use tricoredb::{DocumentFilter, DocumentUpdate};

db.document_create_collection("products")?;
let id = db.document_insert("products", &json!({"name": "widget", "price": 9}))?;
let found = db.document_find("products", &DocumentFilter::gt("price", 5))?;
db.document_update_one("products", &id, &DocumentUpdate::new().inc("price", 1))?;
# let _ = found; Ok(()) }

Aggregation uses the same shape:

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::{AccumulatorOp, AggregateStage, DocumentFilter, GroupAccumulator, GroupKey};

let totals = db.document_aggregate("orders", &[
    AggregateStage::filter(DocumentFilter::eq("status", "paid")),
    AggregateStage::group(
        GroupKey::field("customer"),
        [GroupAccumulator::new("total", AccumulatorOp::sum("amount"))],
    ),
])?;
# let _ = totals; Ok(()) }

Also available: document_insert_with_id, document_get, document_find_limit, document_update, document_upsert_one, document_update_many, document_delete, document_list_collections, document_drop_collection, document_create_index, document_drop_index, document_list_indexes and document_analyze.

Added in 0.3.0, for server 0.1.0-rc.3 and later: document_replace, document_insert_many, document_delete_many, document_bulk_write, document_rename_collection, document_purge_expired, document_explain, document_rebuild_index, document_set_collection_options, document_get_collection_options, document_begin_transaction, document_commit_transaction and document_rollback_transaction.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use serde_json::json;
use tricoredb::{BulkOp, DocumentUpdate};

// One atomic batch: every operation lands or none does.
db.document_bulk_write("orders", &[
    BulkOp::insert_with_id("o1", json!({"total": 10})),
    BulkOp::update_one("o2", DocumentUpdate::new().inc("total", 5)).upsert(),
    BulkOp::delete("o3"),
])?;

// A transaction spans requests. Requests join it by naming it.
let txn = db.document_begin_transaction(false)?;
db.set_request_txn(Some(&txn));
db.document_insert_with_id("orders", "o4", &json!({"total": 1}))?;
db.document_commit_transaction(&txn)?; // also stops naming it
# Ok(()) }

Vectors

Fixed-dimension collections with cosine, dot or L2 distance, metadata filters and optional int8 quantization.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::VectorMetric;

db.vector_create_collection("embeddings", 3, VectorMetric::Cosine)?;
db.vector_upsert("embeddings", "a", &[0.1, 0.2, 0.3], None)?;
let hits = db.vector_search("embeddings", &[0.1, 0.2, 0.3], 5)?;
for hit in &hits.results {
    println!("{} {}", hit.id, hit.score);
}
# Ok(()) }

The score is a similarity: higher is closer under every metric, and results come back best first. L2 is the case worth knowing — the server negates the squared distance, so an L2 score is <= 0 and -0.02 is nearer than -196.0.

Also available: vector_create_collection_quantized, vector_search_filtered, vector_get, vector_delete, vector_list_collections, vector_describe_collection, vector_list_vectors and vector_drop_collection.

Added in 0.3.0, for server 0.1.0-rc.3 and later: vector_upsert_many, vector_upsert_text, vector_upsert_named, vector_add_named_vector, vector_delete_many, vector_delete_by_filter, vector_update_by_filter, vector_count, vector_search_batch, vector_search_multi, vector_search_by_examples, vector_embed, vector_set_alias, vector_list_aliases, vector_list_partitions, vector_rename_collection, vector_copy_collection, vector_set_collection_config, vector_rebuild_index, vector_index_stats and vector_measure_recall. VectorSearchOptions carries the filter, the named vector to search (using) and the other per-search settings; a field left at its default is not sent.

Graphs

Labelled nodes, typed edges, traversal, two shortest-path algorithms and a read-only Cypher subset.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::{NeighborOptions, PathOptions};

db.graph_create("social")?;
db.graph_add_node("social", "u1", &["User"], None)?;
db.graph_add_node("social", "u2", &["User"], None)?;
db.graph_add_edge("social", "e1", "u1", "u2", "FOLLOWS", None)?;

let neighbors = db.graph_neighbors("social", "u1", &NeighborOptions::default())?;
let path = db.graph_shortest_path("social", "u1", "u2", &PathOptions::default())?;
println!("{} hops: {:?}", path.hops, path.node_path);
# let _ = neighbors; Ok(()) }

"No path" comes back as found == false, not as an error, and message says whether the search ran out of graph or stopped at a bound.

Also available: graph_get_node, graph_get_edge, graph_delete_node, graph_delete_edge, graph_list, graph_drop, graph_traverse, graph_weighted_shortest_path, graph_degree, graph_list_nodes, graph_list_edges and graph_query.

Added in 0.3.0, for server 0.1.0-rc.3 and later:

Family Methods
Batch writes graph_add_many, graph_remove_many, graph_update_node, graph_update_edge, graph_copy, graph_import_csv
Walks and paths graph_dfs, graph_subgraph, graph_k_shortest_paths, graph_all_paths
Algorithms graph_connected_components, graph_detect_cycle, graph_centrality, graph_communities, graph_triangle_count, graph_node_similarity, graph_page_rank
Descriptions graph_stats, graph_schema, graph_check_consistency
Indexes and constraints graph_create_node_index, graph_create_constraint, graph_drop_schema_object, graph_list_schema_objects, graph_find_nodes
Cypher writes graph_execute, graph_begin_transaction, graph_commit_transaction, graph_rollback_transaction

A graph transaction queues graph_execute statements sent while set_request_txn names it, and the commit applies them all or none.

LLM context

Read-only export of query results and schema, in TOON, JSON, Markdown or the native format.

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::{DocumentFilter, LlmSource, OutputFormat};

let bundle = db.llm_context(
    &[
        LlmSource::sql("SELECT id, name FROM users"),
        LlmSource::document_find("products", &DocumentFilter::all(), None),
    ],
    OutputFormat::Toon,
    None,
)?;
let schema = db.llm_schema(OutputFormat::Markdown, None)?;
# let _ = (bundle, schema); Ok(()) }

Sensitive fields are redacted by default — LlmOptions::default() matches the server's own defaults, so build from it rather than from a zeroed struct.

Admin

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
db.admin_ping()?;
let status = db.admin_status()?;
# let _ = status; Ok(()) }

Admin calls need the cluster module enabled on the server, even on a single node. db.ping() checks the connection itself and reaches no module.

Errors

Every fallible call returns tricoredb::Error. Branch on kind() and code(), never on the message text:

# fn main() -> tricoredb::Result<()> {
# let mut db = tricoredb::Client::connect(&tricoredb::Options::default())?;
use tricoredb::ErrorKind;

match db.query("SELECT * FROM missing") {
    Ok(rows) => println!("{} rows", rows.len()),
    Err(e) => match e.kind() {
        ErrorKind::InvalidArgument => {}     // refused here; nothing was sent
        ErrorKind::FeatureNotGranted => {}   // the server lacks a capability this call needs
        ErrorKind::Auth => {}                // the credentials were refused
        ErrorKind::Server => {}              // the request arrived; the operation failed
        ErrorKind::Timeout => {}             // no reply within the read timeout
        _ => println!("{e}"),
    },
}
# Ok(()) }

is_connection_fatal() says whether the connection can still be used: a server refusal leaves it perfectly usable, while a timeout or a protocol failure does not. A connection that failed that way is dropped, and every later call on it reports the original failure instead of reading a stale reply as the next answer.

Leader redirects. In a cluster only the leader accepts writes, and a follower refuses with code() == "not_leader" (is_redirect()). The client handles that itself unless follow_leader(false) is set:

  • when the refusal names the leader (leader_hint()), it opens a connection there with the same user, secret and TLS settings (the leader's host is the certificate name), sends the request again, and keeps that connection (address() reports it);
  • while an election is in progress (no hint), it waits and asks the same node again, pausing 250 ms, then 500 ms, up to 2 s;
  • after leader_retries (5) it returns the last refusal.

This is safe because a not_leader refusal was applied nowhere. Nothing is sent again inside a session transaction, and never a refusal with code() == Some(OUTCOME_UNKNOWN): a follower handed that request to the leader and lost the answer, or the leader could not confirm it in time, so it may have been applied. The leader's address must be reachable from your application; when it is not, the client keeps its connection and waits.

TLS

TLS is off unless Options::tls is set. Without it the secret crosses the wire in the clear, so use TLS for anything but local development.

# fn main() -> tricoredb::Result<()> {
use tricoredb::{Client, Options, TlsOptions};

let mut db = Client::connect(
    &Options::new("db.internal", 8427)
        .user("admin")
        .secret("your-password")
        .tls(TlsOptions::new().ca_file("/etc/tricore/ca.pem").server_name("db.internal")),
)?;
# let _ = db.ping(); Ok(()) }

With TLS on, the server certificate and host name are always verified. If ca_file is not set the trust store is empty: the client never falls back to the operating system's roots, so a wrong path fails closed instead of trusting an unrelated CA.

For mutual TLS, TlsOptions::client_identity(cert, key) presents a client certificate. Errors name a certificate or key file's path, never its contents.

TlsOptions field Default Meaning
ca_file None (empty trust store) PEM bundle used to verify the server
server_name "localhost" Expected name (SNI and certificate check)
client_cert_file None PEM client certificate chain, for mutual TLS
client_key_file None PEM private key for that certificate
danger_accept_invalid_certs false Development only. Skips all verification.

Testing

cargo test

The unit tests and the scripted-peer tests in tests/protocol.rs need no server. tests/live.rs starts a real tricore-server: point TRICORE_SERVER_BIN at the binary to run it. Without that, those tests print why and pass.

License

Apache License 2.0