The Go client
go get, and there is nothing to publish — plus a connection that is a session and a builder whose values never reach the text.
go get github.com/tessaridb/tessaridb-sdk-go
Go 1.24 or newer, no dependencies, Apache-2.0.
There is no registry step, and that is not a shortcut. Go has no package registry: a module is fetched from the path that names it. So this import path is owned because the repository is, there was nothing to publish, and the command above works today.
A first read
conn, err := tessaridb.Dial("127.0.0.1:9080", nil)
if err != nil {
return err
}
defer conn.Close()
reply, err := conn.Execute("USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person;", nil)
The second argument to Dial is *Credentials, and nil is the right value
against a store that has no users yet — a store with none declared is
open and runs anything, and the first DEFINE USER closes it.
One outcome per statement, in order. The credential is spent once, on the connection's first request: the store verifies a password with Argon2id at the OWASP floor, so presenting it per statement would pay that cost per statement.
One connection is one session
USE NAMESPACE prod; is still in force on the next Execute on that connection,
and two connections are two sessions. A pooled connection that reconnects has
forgotten its USE, and the read after it goes to the wrong database with no
error — reading a namespace that exists is not a fault. Send the USE with the
work.
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).
pem, err := os.ReadFile("ca.pem")
trust, err := tessaridb.TrustPEM(pem) // or tessaridb.TrustSystem()
conn, err := tessaridb.DialTLS("db.example:9080", creds, trust)
http, err := tessaridb.NewHTTPClientTLS("db.example:8000", creds, trust)
Every connection checks the node's certificate chain and that it names the host
you dialled — a DNS name, or an IP address against the certificate's IP
entries — including each node a redirect sends a request to. A Trust has no
field that skips either check. A failed handshake on the wire is a *TLSError,
which is not retried. From 0.8.0.
A refusal says what to do next
From 0.9.0 a refusal carries its class — (*Refusal).Class (and (*HTTPError).Class), typed as RefusalClass, a string type with one constant per class. Branch on it rather than on the message, which is the store's own prose and changes between releases. It is the empty class 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: *RedirectLoopError, *StaleRedirectError, *WrongNodeError and *NotFollowableError. From 0.7.0.
Building a statement
query, err := tessaridb.Select("memories").
Field("body").
Where(tessaridb.Compare("session", tessaridb.Eq, tessaridb.Text{Value: "abc"})).
OrderBy("created", tessaridb.Descending).
Limit(50).
Render()
// SELECT body FROM memories WHERE session = $p0 ORDER BY created DESC LIMIT 50;
reply, err := conn.Execute(query.Script, query.Parameters)
A value never reaches the statement text; it becomes a bound parameter and travels beside it, encoded. A name is grammar rather than a value, so it is written into the text — which is safe only because it is checked first against a production deliberately narrower than the node's own lexer, and a string that is not a name is refused rather than quoted into acceptance.
On a cluster, .Staleness("30s") and .AnsweredBy("LEADER") say which node
may answer rather than what the answer holds. A bound tighter than the cluster
can know about itself is refused by the node, and the refusal names the floor
— this client checks the shape of a span and never its value, because the floor
belongs to the cluster.
Following changes
changes, failures, err := conn.Changes(0, true, "person")
for change := range changes {
fmt.Println(change.Sequence, change.Table, change.Identity, change.Removed)
}
if err := <-failures; err != nil {
// the node hung up, or refused the subscription
}
Two channels rather than one, because the feed ended and the feed failed are different facts to a subscriber holding a position: one is a reason to reconnect and the other is not.
Subscribing consumes the connection — after it, the socket delivers changes
and no longer answers statements. Send the USE before subscribing: 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 that refusal
arrives on the failures channel.
On a feed over a split table
each Change also carries a Cursor; resume with conn.ChangesAt(cursor, table)
and the node continues after that change. From v0.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:
arrivals, failures, err := conn.ChangesWhere(0, true, tessaridb.Narrowing{
Table: "orders",
Condition: "total > $least",
Parameters: map[string]tessaridb.Value{"least": tessaridb.Integer{Value: 100}},
})
for arrival := range arrivals {
switch a := arrival.(type) {
case tessaridb.Change:
fmt.Println(a.Identity, a.Removed)
case tessaridb.Progress:
resumeAfter = a.Sequence
}
}
On a split table, resume from a change's or a progress's Cursor with ChangesWhereAt.
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: ErrNodeTooOld. 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.
conn, err := tessaridb.Dial("127.0.0.1:9080", nil)
consumer, err := tessaridb.NewConsumer(conn, "app", "main", "jobs", "workers")
ctx, stop := context.WithCancel(context.Background())
defer stop() // calling stop() from anywhere ends the loop
// Automatic: nil acknowledges the message, an error hands it back at once.
err = consumer.RunAuto(ctx, func(ctx context.Context, m tessaridb.Message) error {
fmt.Println(m.Position, m.Deliveries, m.Value)
return nil
})
// Manual: return tessaridb.Ack{}, tessaridb.Nack{Delay: 5 * time.Second}, or
// tessaridb.Leave{} for the group's deadline to hand it out again.
err = consumer.RunManual(ctx, func(ctx context.Context, m tessaridb.Message) tessaridb.Settle {
return tessaridb.Ack{}
})
When the context is done the running handler finishes, its acknowledgement is
sent, and the loop returns nil. Both modes are at least once: make an
effect outside the store idempotent, keyed by the topic, the group and
m.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; a ttl of zero
means no expiry (node 0.15.0-beta and later):
cache, err := tessaridb.NewCache(conn, "app", "main", "cache")
err = cache.Set("session:abc", tessaridb.Text{Value: "ada"}, 30*time.Minute)
lease, err := cache.Lock("nightly-report", 30*time.Second, "")
if err == nil && lease != nil {
_, err = lease.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 error this package returns:
if _, err := conn.Unseal(storePassphrase); err != nil { // the store's key, for ten minutes
return err
}
vault, err := tessaridb.NewVault(conn, "app", "main", "team")
if err != nil {
return err
}
id := tessaridb.Text{Value: "github"}
err = vault.Write(id, map[string]tessaridb.Value{"password": tessaridb.Text{Value: "hunter2"}})
page, err := vault.List(nil, 100) // ids only, never a value
secret, err := vault.Reveal(id, "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.
Objects, files and health
node := tessaridb.NewHTTPClient("127.0.0.1:8000", &tessaridb.Credentials{
User: "app",
Password: os.Getenv("TESSARIDB_PASSWORD"),
})
err := node.Put("prod", "app", "uploads", "reports/100% done.pdf", bytes)
back, err := node.Get("prod", "app", "uploads", "reports/100% done.pdf")
files, err := node.List("prod", "app", "uploads")
health, err := node.Health()
Remove is idempotent — the server answers the same whether or not the file was
there, so a client that claimed to know which had happened would be inventing it.
A listing's path carries a leading slash: a file written as logo.png
lists as /logo.png. That is the specified shape and it is not the name to hand
back — strip exactly one, never all of them, because a file may genuinely be
named with a leading slash.
Appending events to a series
landed, err := node.Append("prod", "metrics", "readings", []tessaridb.Value{
tessaridb.Object{Fields: map[string]tessaridb.Value{
"sensor": tessaridb.Text{Value: "s1"},
"at": tessaridb.Datetime{Seconds: 1_790_676_000},
}},
})
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 — bytes, a range, a non-finite float — returns a *NotAnEventError before anything is sent. See
Time-series for
what a series does with the events.