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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# tricoredb

Official Rust client for [TriCoreDB](https://hub.docker.com/r/trinesh14/tricoredb):
SQL, documents, vectors, graphs and cache over one native connection.

[![crates.io](https://img.shields.io/crates/v/tricoredb?logo=rust&label=crates.io&color=blue&cacheSeconds=1800)](https://crates.io/crates/tricoredb)
[![docs.rs](https://img.shields.io/docsrs/tricoredb?logo=docsdotrs&label=docs&cacheSeconds=1800)](https://docs.rs/tricoredb)
[![license](https://img.shields.io/crates/l/tricoredb?label=license&color=blue&cacheSeconds=1800)](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]#requirements
- [Installation]#installation
- [Running a server]#running-a-server
- [Quick start]#quick-start
- [Connecting]#connecting
- [SQL]#sql
- [Transactions]#transactions
- [Connection pool]#connection-pool
- [Cache]#cache
- [Documents]#documents
- [Vectors]#vectors
- [Graphs]#graphs
- [LLM context]#llm-context
- [Admin]#admin
- [Errors]#errors
- [TLS]#tls
- [Testing]#testing

## 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]#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

```bash
cargo add tricoredb
```

Or in `Cargo.toml`:

```toml
[dependencies]
tricoredb = "0.3"
```

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

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

## Running a server

The quickest way is the official Docker image,
[`trinesh14/tricoredb`](https://hub.docker.com/r/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:

```bash
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](https://hub.docker.com/r/trinesh14/tricoredb) to create the
certificate and key, then connect with [TLS](#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

```rust,no_run
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]#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:

```rust
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](#connection-pool) for concurrency.

## SQL

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

```rust,no_run
# 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:

```rust,no_run
# 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`:

```rust,no_run
# 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.

```rust,no_run
# 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.

```rust,no_run
# 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` |

```rust,no_run
# 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.

```rust,no_run
# 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:

```rust,no_run
# 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`.

```rust,no_run
# 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.

```rust,no_run
# 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.

```rust,no_run
# 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.

```rust,no_run
# 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

```rust,no_run
# 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:

```rust,no_run
# 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.

```rust,no_run
# 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

```bash
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](LICENSE)