The Kotlin client
A Gradle dependency with nothing behind it, a typed wire surface, and an HTTP surface that hands you bytes on purpose.
// build.gradle.kts
dependencies {
implementation("com.tessaridb:tessaridb-client:0.10.0")
}
JVM 17 or newer, no runtime dependencies, Apache-2.0. That constraint is deliberate rather than an accident of being early: a database client is something you add to a service that already has opinions about JSON, HTTP and coroutines, and every dependency it brings is one you have to reconcile.
Not on Maven Central yet. Until it is, clone the repository beside your project and add one line to
settings.gradle.kts:includeBuild("../tessaridb-sdk-kotlin"). The dependency above then resolves from the included build, with no version.
The library is built for JVM 17 bytecode rather than pinned to a JDK 17 toolchain, so it loads on the JDK your service already runs and building it does not require a second one.
A first read
import com.tessaridb.*
connect("127.0.0.1:9080", user = "root", password = "…").use { db ->
val reply = db.execute("USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person;")
val records = reply.outcomes.last() as Records
for (row in records.rows) println("${row.identity} ${row.value}")
}
The address is a bare host:port — there is no URL scheme. Connection is an
AutoCloseable, so use closes it.
One outcome per statement, in order. Credentials are optional: a store with no users declared is open and runs anything. The credential is spent once, on the connection's first request, because the store verifies a password with Argon2id at the OWASP floor and the session is the connection.
Parameters cannot become syntax
val reply = db.execute(
"USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person WHERE name = \$who;",
mapOf("who" to TextValue("'; DROP COLLECTION person; --")),
)
Note the \$: a Kotlin string would otherwise interpolate it, and the parameter
belongs to the node rather than to the language. The value travels encoded,
never as text — a string that spells a statement is stored as a string that
spells a statement.
Building a statement
val rendered = select("memories")
.field("body")
.where(compare("session", Operator.EQ, TextValue("abc")))
.orderBy("created", Direction.DESC)
.limit(50)
.render()
// SELECT body FROM memories WHERE session = $p0 ORDER BY created DESC LIMIT 50;
db.execute(rendered.script, rendered.parameters)
SELECT, CREATE, UPDATE and DELETE over one collection; anything else you
write as a script and send as one. A name that is not a name throws
BuilderException carrying the reason — NOT_A_NAME, and the position it was
given in — rather than being quoted into acceptance.
On a cluster, .staleness("30s") and .answeredBy("LEADER") say which node
may answer. The span's shape is checked here and its value never is: a
bound tighter than the cluster's floor is the node's refusal to make, and its
message names the floor.
Over TLS
A node given a certificate speaks TLS 1.3 on both ports and nothing else, and a
node in a cluster serves clients in the clear only when its operator chose to
(node 0.21.0-beta and later; Encryption in transit).
val trust = Trust.fromPem(File("ca.pem").readBytes()) // or Trust.system()
val connection = connect("db.example:9080", "ada", password, trust)
val http = HttpSurface("db.example:8000", "ada", password, trust)
Every connection checks the node's certificate chain and that it names the host
you dialled, including each node a redirect sends a request to; a Trust has no
way to turn either check off. A failed handshake is a TlsException, which is
not retried. From 0.8.0.
A refusal says what to do next
From 0.9.0 a refusal carries its class — RefusedException.refusalClass (and HttpException.refusalClass), typed as the RefusalClass enum. Branch on it rather than on the message, which is the store's own prose and changes between releases. It is null from a node before 0.30.0-beta, which sends the words alone, and a class this build does not know reads as unknown, which is not retried.
A redirect is followed
On a cluster, a request sent to a node that should not answer it comes back as a
redirect (where to go), and execute follows it before returning — at most three hops,
never to an older leadership than one already followed, the session's namespace
and database selected again on arrival, and only to the node the redirect named.
A settled redirect moves the connection there; a transient one answers there
and leaves the connection where it was. Each way following can stop is its own
error: RedirectLoopException, StaleRedirectException, WrongNodeException and NotFollowableException. From 0.7.0.
Following changes
connect("127.0.0.1:9080").use { watcher ->
watcher.execute("USE NAMESPACE prod; USE DATABASE app;")
val changes = watcher.subscribe(from = 0, table = "person")
for (change in changes) {
println("${change.sequence} ${change.table} ${change.identity} ${change.removed}")
}
}
Subscribing consumes the connection, so a caller that wants both opens two.
The USE in front of it is not decoration: the table is resolved against that
connection's own session, and a connection that has named no namespace has
nothing to watch — the node refuses on the feed, and the refusal arrives as
RefusedException carrying the node's own words rather than as a protocol error.
changes.resumeFrom is the last sequence handled plus one. That arithmetic is
the client's rather than yours: resuming at a position already handled delivers
it twice and resuming past one reports being caught up, and both are silent.
On a feed over a split table
each Change also carries a cursor, and changes.resumeCursor holds the last
one: subscribe again with subscribe(table = …, cursor = changes.resumeCursor)
and the node continues after it. From 0.2.0.
A feed narrowed by a condition
A feed over one table can be narrowed by a condition — TessariQL without WHERE, its values bound
rather than written into it:
val feed = connection.subscribeWhere("orders", "total > \$least", mapOf("least" to IntegerValue(100)))
for (arrival in feed) {
when (arrival) {
is Change -> println("${arrival.identity} ${arrival.removed}")
is Progress -> {} // resumeFrom has moved past the changes it skipped
}
}
A record that stops matching arrives as a removal, so a mirror applying the feed holds exactly the matching
records. A feed that skipped changes hands over a Progress — store it as you store a change's position, or the
resume point falls behind a pruned log. Only a node of minor 4 (0.33.0-beta and later) reads a condition, and an
older one would send every change, so the client refuses before anything is sent: NodeTooOldException. See
narrowing a feed. From 0.10.0.
Consuming a topic
A topic's consumer group hands each message to one member and forgets it only when it is acknowledged. Consumer reads under a group and calls your function once per message, in order. From 0.3.0, against an engine at 0.12.0-beta or later.
val connection = connect("127.0.0.1:9080")
val consumer = Consumer(connection, "app", "main", "jobs", "workers")
// Automatic: returning acknowledges the message, throwing hands it back at once.
thread { consumer.runAuto { message -> println("${message.position} ${message.value}") } }
consumer.stop() // from anywhere: the running handler finishes, then the loop ends
// Manual: return Settle.Ack, Settle.Nack(Duration.ofSeconds(5)) or Settle.Leave.
consumer.runManual { Settle.Ack }
The loop blocks its thread, like every call in this client, and needs no
dependency to do it. Both modes are at least once: make an effect outside
the store idempotent, keyed by the topic, the group and message.position. The
group, not the connection, holds the state, and it is declared in the store
rather than by the consumer. The behaviour is the protocol repository's
spec/consumer-v1.md, which every client follows.
A space as a cache
Cache makes each cache use one call over a connection you hold, on the wire, so
it needs no JSON reader (node 0.15.0-beta and later):
val cache = Cache(connection, "app", "main", "cache")
cache.set("session:abc", TextValue("ada"), Duration.ofMinutes(30))
val page = cache.getOrSet("page:/", Duration.ofMinutes(1)) { TextValue("<html>") }
cache.lock("nightly-report", Duration.ofSeconds(30))?.let { it.release() }
The two rules are the page's own: a plain set clears an expiry, and a lock is a lease whose release is an expiring conditional write, never a delete. The full surface is A cache over HTTP and in the clients.
A vault, and its passphrase
A vault (DEFINE VAULT) keeps SECRET fields encrypted in every copy that is not a
running, unsealed node. The passphrase goes in a frame of its own, never in a
statement, and is in no exception this client throws:
val conn = connect("127.0.0.1:9080")
conn.unseal(storePassphrase) // the store's key, for ten minutes
val vault = Vault(conn, "app", "main", "team")
vault.write("github", mapOf("password" to TextValue("hunter2"))) // creates or edits, keeps recipients
val page = vault.list(limit = 100) // ids only, never a value
val secret = vault.reveal("github", listOf("password"))
A vault declared DEFINE VAULT team PASSPHRASE '…' opens with its own passphrase
instead, and the store's opens nothing in it: vault.status(), vault.unseal(…),
vault.seal() and vault.changePassphrase(…) act on that vault alone, and
status().custody says which kind a vault is. An unseal lasts the node's period and
then closes by itself; a refusal after a run of wrong passphrases means wait, and
is not retried here.
What unsealing opens, and what a vault with its own passphrase changes, is on
Vaults.
The HTTP surface hands you bytes, on purpose
val node = HttpSurface("127.0.0.1:8000", user = "app", password = System.getenv("TESSARIDB_PASSWORD"))
node.put("prod", "app", "uploads", "reports/100% done.pdf", bytes)
val back: ByteArray? = node.get("prod", "app", "uploads", "reports/100% done.pdf")
val listing: String? = node.listing("prod", "app", "uploads")
val health: String = node.health()
The JVM has no JSON reader in its standard library, so a typed result here would mean this client choosing a JSON library for every service that adds it — the one thing a zero-dependency client promised not to do. Every sibling client got its reader free from its own standard library; this one would have to import somebody's.
So the typed surface is the wire, where a value arrives in the store's own
codec and no JSON is involved at all, and this surface is for what HTTP is
actually for: files, a backup, /health and /ready. It hands back the body and
you parse it with whatever your service already has.
get answers null when the file is not there and zero bytes when it is there
and empty — different facts, and the server draws the line. listing answers
null when the name is not a bucket.
One call takes typed values: appending events to a series
val landed: Long = node.append(
"prod", "metrics", "readings",
listOf(ObjectValue(mapOf("sensor" to TextValue("s1"), "at" to DatetimeValue(1_790_676_000, 0)))),
)
The route's answer is a single count, so reading it needs no JSON library.
From 0.4.0, against an engine at 0.14.0-beta or later. The batch lands
whole or not at all, and the answer is how many landed. It is sent once: appending is not idempotent, so a transport failure after the
request left is yours to judge. A kind an event cannot carry throws NotAnEventException before anything is sent. See
Time-series for
what a series does with the events.