Transistor

A Rust Crux Client crate/lib. For now, this crate intends to support 2 ways to interact with Crux:

Other solutions may be added after the first release.

Bitemporal Crux

Crux is optimised for efficient and globally consistent point-in-time queries using a pair of transaction-time and valid-time timestamps.

Ad-hoc systems for bitemporal recordkeeping typically rely on explicitly tracking either valid-from and valid-to timestamps or range types directly within relations. The bitemporal document model that Crux provides is very simple to reason about and it is universal across the entire database, therefore it does not require you to consider which historical information is worth storing in special "bitemporal tables" upfront.

One or more documents may be inserted into Crux via a put transaction at a specific valid-time, defaulting to the transaction time (i.e. now), and each document remains valid until explicitly updated with a new version via put or deleted via delete.

Why?

| Time | Purpose | |- |- | | transaction-time | Used for audit purposes, technical requirements such as event sourcing. | | valid-time | Used for querying data across time, historical analysis. |

transaction-time represents the point at which data arrives into the database. This gives us an audit trail and we can see what the state of the database was at a particular point in time. You cannot write a new transaction with a transaction-time that is in the past.

valid-time is an arbitrary time that can originate from an upstream system, or by default is set to transaction-time. Valid time is what users will typically use for query purposes.

Reference crux docs and value of bitemporality

Usage

To add this crate to your project you should add one of the following line to your dependencies field in Cargo.toml: >

[dependencies] transistor = "1.0.0"

Creating a Crux Client

All operations with Transistor start in the module client with Crux::new("localhost", "3000"). The struct Crux is responsabile for defining request HeadersMap and the request URL. The URL definition is required and it is done by the static function new, which receives as argument a host and a port and returns a Crux instance. To change HeadersMap info so that you can add AUTHORIZATION you can use the function with_authorization that receives as argument the authorization token and mutates the Crux instance. * HeaderMap already contains the header Content-Type: application/edn.

Finally, to create a Crux Client the function <type>_client should be called, for example http_client. This function returns a struct that contains all possible implementarions to query Crux Docker and Standalone HTTP Server. ```rust use transistor::client::Crux;

// HttpClient with AUTHORIZATION let authclient = Crux::new("127.0.0.1","3000").withauthorization("my-auth-token").http_client();

// HttpClient without AUTHORIZATION let client = Crux::new("127.0.0.1","3000").http_client(); ```

Http Client

Once you have called http_client you will have an instance of the HttpClient struct which has a bunch of functions to query Crux on Docker and Standalone HTTP Server: * state queries endpoint / with a GET. No args. Returns various details about the state of the database. ```rust let body = client.state().unwrap();

// StateResponse { // indexindexversion: 5, // doclogconsumerstate: None, // txlogconsumerstate: None, // kvkvstore: "crux.kv.rocksdb.RocksKv", // kvestimatenumkeys: 56, // kvsize: 2271042 // } ```

let person1 = Person { cruxdb_id: CruxId::new("jorge-3"), .. };

let person2 = Person { cruxdb_id: CruxId::new("manuel-1"), .. };

let action1 = Action::Put(person1.serialize()); let action2 = Action::Put(person2.serialize());

let body = client.tx_log(vec![action1, action2]).unwrap(); // {:crux.tx/tx-id 7, :crux.tx/tx-time #inst \"2020-07-16T21:50:39.309-00:00\"} ```

let body = client.tx_logs().unwrap();

// TxLogsResponse { // txevents: [ // TxLogResponse { // txtxid: 0, // txtxtime: 2020-07-09T23:38:06.465-00:00, // txeventtxevents: Some( // [ // [ // ":crux.tx/put", // "a15f8b81a160b4eebe5c84e9e3b65c87b9b2f18e", // "125d29eb3bed1bf51d64194601ad4ff93defe0e2", // ], // ], // ), // }, // TxLogResponse { // txtxid: 1, // txtxtime: 2020-07-09T23:39:33.815-00:00, // txeventtx_events: Some( // [ // [ // ":crux.tx/put", // "a15f8b81a160b4eebe5c84e9e3b65c87b9b2f18e", // "1b42e0d5137e3833423f7bb958622bee29f91eee", // ], // ], // ), // }, // ... // ] // } ```

let client = Crux::new("localhost", "3000").http_client();

let ednbody = client.entity(person.cruxdbid.serialize()).unwrap(); // Map( // Map( // { // ":crux.db/id": Key( // ":hello-entity", // ), // ":first-name": Str( // "Hello", // ), // ":last-name": Str( // "World", // ), // }, // ), // ) ```

let person = Person { cruxdb_id: CruxId::new("hello-entity"), ... };

let client = Crux::new("localhost", "3000").http_client();

let txbody = client.entitytx(person.cruxdbid.serialize()).unwrap(); // EntityTxResponse { // dbid: "d72ccae848ce3a371bd313865cedc3d20b1478ca", // dbcontenthash: "1828ebf4466f98ea3f5252a58734208cd0414376", // dbvalidtime: 2020-07-20T20:38:27.515-00:00, // txtxid: 31, // tx_tx_time: 2020-07-20T20:38:27.515-00:00, // } ```

let person = Person { cruxdb_id: CruxId::new("hello-history"), ...

let client = Crux::new("localhost", "3000").http_client();

let txbody = client.entitytx(person.cruxdb_id.serialize()).unwrap();

let entityhistory = client.entityhistory(txbody.dbid.clone(), Order::Asc, true); // EntityHistoryResponse { history: [ // EntityHistoryElement { // dbvalidtime: 2020-08-05T03:00:06.476-00:00, // txtxid: 37, txtxtime: 2020-08-05T03:00:06.476-00:00, // dbcontenthash: "2da097a2dffbb9828cd4377f1461a59e8454674b", // dbdoc: Some(Map(Map( // {":crux.db/id": Key(":hello-history"), // ":first-name": Str("Hello"), // ":last-name": Str("World")} // ))) // } // ]}

let entityhistorywithoutdocs = client.entityhistory(txbody.dbid, Order::Asc, false); // EntityHistoryResponse { // history: [ // EntityHistoryElement { // dbvalidtime: 2020-08-05T03:00:06.476-00:00, // txtxid: 37, // txtxtime: 2020-08-05T03:00:06.476-00:00, // dbcontenthash: "2da097a2dffbb9828cd4377f1461a59e8454674b", // dbdoc: None // } // } // ]} ```

let client = Crux::new("localhost", "3000").http_client();

let queryissql = Query::find(vec!["?p1", "?n"]) .where_clause(vec!["?p1 :name ?n", "?p1 :is-sql true"]) .build(); // Query: // {:query // {:find [?p1 ?n] // :where [[?p1 :name ?n] // [?p1 :is-sql true]]}}

let issql = client.query(queryis_sql.unwrap()).unwrap(); // {[":mysql", "MySQL"], [":postgres", "Postgres"]} BTreeSet ```

Action is an enum with a set of options to use in association with the function tx_log: * Put - Write a version of a document * Delete - Deletes the specific document at a given valid time * Evict - Evicts a document entirely, including all historical versions (receives only the ID to evict) * Match - Matches the current state of an entity, if the state doesn't match the provided document, the transaction will not continue

Query is a struct responsible for creating the fields and serializing them into the correct query format. It has a function for each field and a build function to help check if it is correctyly formatted. * find is a static builder function to define the elements inside the :find clause. * where_clause is a builder function that defines the vector os elements inside the :where [] array. * order_by is a builder function to define the elements inside the :order-by clause. * args is a builder function to define the elements inside the :args clause. * limit is a builder function to define the elements inside the :limit clause. * offset is a builder function to define the elements inside the :offset clause. * with_full_results is a builder function to define the flag full-results? as true. This allows your query response to return the whole document instead of only the searched keys. The result of the Query {:query {:find [?user ?a] :where [[?user :first-name ?a]] :full-results? true}} will be a BTreeSet<Vec<String>> like ([{:crux.db/id :fafilda, :first-name "Jorge", :last-name "Klaus"} "Jorge"]), so the document will need further EDN parsing to become the document's struct.

Errors are defined in the CruxError enum. * ParseEdnError is originated by edn_rs crate. The provided EDN did not match schema. * RequestError is originated by reqwest crate. Failed to make HTTP request. * QueryFormatError is originated when the provided Query struct did not match schema. * QueryError is responsible for encapsulation the Stacktrace error from Crux response: ```rust use transistor::client::Crux; use transistor::types::{query::Query};

let client = Crux::new("localhost", "3000").httpclient();

// field n doesn't exist let queryerrorresponse = Query::find(vec!["?p1", "?n"]) .whereclause(vec!["?p1 :name ?g", "?p1 :is-sql true"]) .build();

let error = client.query(queryerrorresponse?)?; println!("Stacktrace n{:?}", error);

// Stacktrace // QueryError("{:via // [{:type java.lang.IllegalArgumentException, // :message \"Find refers to unknown variable: n\", // :at [crux.query$q invokeStatic \"query.clj\" 1152]}], // :trace // [[crux.query$q invokeStatic \"query.clj\" 1152] // [crux.query$q invoke \"query.clj\" 1099] // [crux.query$q$fn10850 invoke \"query.clj\" 1107] // [clojure.core$bindingconveyorfn$fn5754 invoke \"core.clj\" 2030] // [clojure.lang.AFn call \"AFn.java\" 18] // [java.util.concurrent.FutureTask run \"FutureTask.java\" 264] // [java.util.concurrent.ThreadPoolExecutor // runWorker // \"ThreadPoolExecutor.java\" // 1128] // [java.util.concurrent.ThreadPoolExecutor$Worker // run // \"ThreadPoolExecutor.java\" // 628] // [java.lang.Thread run \"Thread.java\" 834]], // :cause \"Find refers to unknown variable: n\"} // ") ```

Testing the Crux Client

For testing purpose there is a feature called mock that enables the http_mock function that is a replacement for the http_client function. To use it run your commands with the the flag --features "mock" as in cargo test --test lib --no-fail-fast --features "mock". The mocking feature uses the crate mockito = "0.26" as a Cargo dependency. An example usage with this feature enabled:

```rust use transistor::client::Crux; use transistor::http::Action; use transistor::ednrs::{serstruct, Serialize}; use transistor::types::{CruxId}; use mockito::mock;

[test]

[cfg(feature = "mock")]

fn mockclient() { let _m = mock("POST", "/tx-log") .withstatus(200) .matchbody("[[:crux.tx/put { :crux.db/id :jorge-3, :first-name \"Michael\", :last-name \"Jorge\", }], [:crux.tx/put { :crux.db/id :manuel-1, :first-name \"Diego\", :last-name \"Manuel\", }]]") .withheader("content-type", "text/plain") .with_body("{:crux.tx/tx-id 8, :crux.tx/tx-time #inst \"2020-07-16T21:53:14.628-00:00\"}") .create();

let person1 = Person {
    // ...
};

let person2 = Person {
    /// ...
};

let actions = vec![Action::Put(person1.serialize()), Action::Put(person2.serialize())];

let body = Crux::new("localhost", "3000")
    .http_mock()
    .tx_log(actions)
    .unwrap();

assert_eq!(
    format!("{:?}", body),
    String::from("TxLogResponse { tx___tx_id: 8, tx___tx_time: 2020-07-16T21:53:14.628-00:00, tx__event___tx_events: None }")
);

}

serstruct! { #[derive(Debug, Clone)] #[allow(nonsnakecase)] pub struct Person { cruxdbid: CruxId, // ... } }

```

Enababling feature time_as_str

It is possible to use receive the responses (TxLogResponse, EntityTxResponse, EntityHistoryElement) time dates as Strings, to do so you have to enable feature time_as_str:

toml transistor = { version = "1.0.0", features = ["time_as_str"] }

Dependencies

A strong dependency of this crate is the edn-rs crate, as many of the return types are in the Edn format. The sync http client is reqwest with blocking feature enabled. Chrono for time values that can be DateTime<Utc>, for inserts, and DateTime<FixedOffset>, for reads, and mockito for feature mock.

Licensing

This project is licensed under LGPP-3.0 (GNU Lesser General Public License v3.0).