Project

tricoredb

0.0
The project is in a healthy, maintained state
A dependency-free Ruby client for TriCoreDB: SQL with server-side parameters, session transactions, document, vector, graph, cache, LLM context and admin operations over the native framed protocol, with TLS, timeouts, cancellation and a thread-safe pool.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.16
~> 13.0
 Project Readme

tricoredb

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

Gem Version Downloads License

  • No dependencies beyond the Ruby standard library.
  • Server-side parameters. Values never become part of the SQL text.
  • Typed errors you rescue by class and branch on by code.
  • Transactions, a thread-safe pool, TLS and mutual TLS.

Contents

  • Requirements
  • Installation
  • Running a server
  • Quick start
  • Connecting
  • SQL
  • Transactions
  • Connection pool
  • Cache
  • Documents
  • Vectors
  • Graphs
  • LLM context
  • Admin
  • Errors
  • TLS
  • Testing

Requirements

  • Ruby 3.1 or later
  • A TriCoreDB server speaking protocol 1.0 (tricore-server 0.1.0-rc.1 or later). See Running a server.

Installation

gem install tricoredb

Or in a Gemfile:

gem "tricoredb"

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 raises TriCoreDB::ServerError whose code is engine.disabled.

Quick start

require "tricoredb"

db = TriCoreDB::Client.connect(host: "127.0.0.1", port: 8427, user: "admin", secret: "your-password")

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

rows = db.query("SELECT id, name FROM users WHERE id = ?", [1])
rows.first          # => ["1", "O'Hara"]
rows.to_hashes      # => [{"id" => "1", "name" => "O'Hara"}]

db.cache_set("sessions", "u1", "token")
db.cache_get("sessions", "u1")   # => "token", or nil on a miss

db.close

Connecting

TriCoreDB::Client.connect opens one authenticated connection.

Keyword Default Meaning
host "127.0.0.1" Server host
port 8427 Server port
user nil Principal to authenticate as; nil skips authentication
secret "" Password or token
database "main" Database named in every request
connect_timeout 10 Seconds allowed for the connect, TLS and handshake
read_timeout nil Seconds allowed for each reply
request_timeout_ms nil Server-side deadline stamped on each request
tls nil See TLS
client_name "tricoredb-ruby/<version>" Name reported in the handshake
features every capability Bitmap announced in the handshake

A connection is a single request/response stream, so one connection serves one thread. For concurrent work use a pool.

SQL

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

db.execute("INSERT INTO users VALUES (?, ?)", [2, "ada"])
rows = db.query("SELECT id, name FROM users")

rows.size           # => 2
rows.each { |row| puts row.inspect }
rows.to_hashes      # rows keyed by column name

Placeholders are bound on the server: the values travel next to the statement, so a value can never be read as SQL syntax, however it is spelled. How a Ruby value goes out:

Ruby value Sent as
nil, true, false themselves
Integer an exact number, Bignum included
Float a number; NaN and infinities are refused
BigDecimal plain digits, no exponent — for DECIMAL
String (UTF-8) text; invalid UTF-8 is refused
String (ASCII-8BIT) or TriCoreDB::Binary 0x-prefixed hex, for BLOB
Time, Date, DateTime ISO-8601 text

Anything else is refused by name rather than pushed through to_s:

db.execute("INSERT INTO files VALUES (?, ?)", [1, TriCoreDB::Binary.new(File.binread("a.png"))])
db.execute("INSERT INTO t VALUES (?)", [[1, 2]])   # raises TriCoreDB::ParameterError

Binding needs the SERVER_PARAMS capability, agreed in the handshake (db.features[:server_params]). Against a server that did not grant it, a call with parameters raises TriCoreDB::FeatureNotGranted before anything is sent — it never falls back to pasting values into the statement text.

Transactions

transaction sends a whole BEGIN … COMMIT script in one request, and rolls back if the block raises:

db.transaction do
  db.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [10, 1])
  db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [10, 2])
end

begin_transaction, commit and rollback keep a transaction open across requests on this connection, so a later statement can depend on what an earlier one read. They need the SESSION_TXN capability; without it begin_transaction raises by name rather than running each statement on its own.

db.begin_transaction
begin
  db.execute("INSERT INTO t VALUES (?, ?)", [1, "ada"])
  db.commit
rescue StandardError
  db.rollback
  raise
end

The transaction belongs to this connection: another connection cannot commit it, and a dropped socket rolls it back. db.in_transaction? says whether one is open.

Connection pool

pool = TriCoreDB::Pool.new(size: 8, host: "db.internal", user: "admin", secret: "your-password")

threads = 8.times.map do |i|
  Thread.new { pool.with { |db| db.execute("INSERT INTO users VALUES (?, ?)", [i, "grace"]) } }
end
threads.each(&:join)

pool.stats   # => {size: 8, created: 8, idle: 8, in_use: 0, waiting: 0}
pool.close

pool.with lends a connection for the duration of the block. A connection is never returned with a transaction still open, and a broken one is retired rather than handed to the next caller. When every connection is busy, a caller waits up to checkout_timeout seconds and then gets TriCoreDB::PoolTimeout — the pool never grows past size.

Cache

Values are byte strings. nil is a miss, which is how a miss is told apart from a stored empty value.

db.cache_set("sessions", "u1", "token")
db.cache_set("sessions", "u2", "token", ttl_ms: 30_000)
db.cache_get("sessions", "u1")          # => "token" | nil

db.cache_incr("counters", "hits")
db.cache_rpush("queue", "jobs", %w[a b])
db.cache_sadd("tags", "post:1", %w[ruby db])
db.cache_hset("user:1", "profile", [%w[name ada]])
id = db.cache_xadd("events", "log", [%w[msg hi]])
Family Methods
Keys cache_get, cache_set, 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_hget, cache_hdel, cache_hgetall, cache_hexists?, cache_hlen
Streams cache_xadd, cache_xlen, cache_xrange, cache_xread, cache_xdel, cache_xtrim

Documents

Filters and pipeline stages are built with TriCoreDB::Filter, TriCoreDB::Stage and TriCoreDB::Acc, so the request JSON is never written by hand.

db.doc_create_collection("products")
id = db.doc_insert("products", { "name" => "widget", "price" => 9 })
db.doc_find("products", TriCoreDB::Filter.gt("price", 5))
db.doc_update_one("products", id, inc: { "price" => 1 })

totals = db.doc_aggregate("orders", [
  TriCoreDB::Stage.match(TriCoreDB::Filter.eq("status", "paid")),
  TriCoreDB::Stage.group(TriCoreDB::Stage.by_field("customer"), [TriCoreDB::Acc.sum("total", "amount")]),
  TriCoreDB::Stage.sort([["total", true]]),
  TriCoreDB::Stage.limit(10)
])

Also available: doc_get, doc_update, doc_update_many, doc_delete, doc_list_collections, doc_drop_collection, doc_create_index, doc_drop_index, doc_list_indexes and doc_analyze.

Vectors

db.vector_create_collection("embeddings", 3, metric: "cosine")
db.vector_upsert("embeddings", "a", [0.1, 0.2, 0.3], metadata: { "kind" => "doc" })

hits = db.vector_search("embeddings", [0.1, 0.2, 0.3], 5)
hits.first["id"]      # the nearest vector comes first
hits.first["score"]

db.vector_search("embeddings", [0.1, 0.2, 0.3], 5, filter: { "kind" => "doc" })

The score is a similarity: higher is closer under every metric. 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.

Graphs

db.graph_create("social")
db.graph_add_node("social", "u1", labels: ["User"], properties: { "name" => "ada" })
db.graph_add_node("social", "u2", labels: ["User"])
db.graph_add_edge("social", "e1", "u1", "u2", "FOLLOWS")

db.graph_neighbors("social", "u1")
path = db.graph_shortest_path("social", "u1", "u2")
path["found"]      # => true
path["node_path"]  # => ["u1", "u2"]

"No path" comes back as found => false, not as an error. 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 for the read-only Cypher subset.

LLM context

bundle = db.llm_context([
  TriCoreDB::LlmSource.sql("SELECT id, name FROM users"),
  TriCoreDB::LlmSource.documents("products", limit: 50)
], format: "toon")

schema = db.llm_schema(format: "markdown")

Sensitive fields are redacted by default (redact_sensitive: true).

Admin

db.admin_ping
db.admin_status

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 failure is a TriCoreDB::Error. Rescue the class you mean, and branch on code rather than on the message text:

Class Means
ServerError The request arrived and the operation failed. The connection stays usable.
AuthError The credentials were refused.
ProtocolError An ERROR frame, or a peer that broke the protocol.
ConnectionError The transport failed, or the connection was already closed.
ReadTimeout No reply in time; the connection is dropped, because the late reply must not be read as the next answer.
FeatureNotGranted The server lacks a capability this call needs, so nothing was sent.
ParameterError A value with no SQL form; nothing was sent.
PoolTimeout No pooled connection became free in time.

Leader redirects. In a cluster, a write that reaches a follower fails with code == "not_leader", which error.redirect? and response.redirect? test. When the cluster knows the leader, leader_hint holds its host:port; a nil hint means the destination is unknown yet, so wait and retry — it does not mean the failure was something else. This driver does not follow the redirect for you.

begin
  db.execute("INSERT INTO t VALUES (1)")
rescue TriCoreDB::ServerError => e
  raise unless e.code == TriCoreDB::NOT_LEADER
  retry_against(e.leader_hint) if e.leader_hint
end

TLS

TLS is off until tls: is given.

db = TriCoreDB::Client.connect(
  host: "db.internal", user: "admin", secret: "your-password",
  tls: { ca_file: "/etc/tricore/ca.pem", server_name: "db.internal" }
)

tls: true uses the system trust store. The options are ca_file, server_name, client_cert_file and client_key_file (both or neither, for mutual TLS), and danger_accept_invalid_certs — development only, since it encrypts the traffic while authenticating nobody.

Testing

rake test              # unit tests and the scripted-peer tests; no server needed
rake test:integration  # the live tests, against a private tricore-server

The scripted-peer tests play the answers a real cluster would send — a not_leader refusal, a refused handshake, a frame that declares more bytes than it sends — so they need no server. The live tests start their own tricore-server on an ephemeral port: point TRICORE_SERVER_BIN at the binary, and without one they skip.

License

Apache License 2.0