TessariDBDocs
  • 0.34.2-betalatest
  • 0.34.1-beta
  • 0.34.0-beta
  • 0.33.4-beta
  • 0.33.3-beta
  • 0.33.2-beta
  • 0.33.1-beta
  • 0.33.0-beta
  • 0.32.0-beta

The HTTP surface

The paths, a password spent once for a token, and three probes that deliberately take no credential.

$ tessaridb ./data --http 0.0.0.0:8000

Authentication is HTTP Basic or a bearer token, and nothing else. Given a certificate (--tls-cert and --tls-key, from 0.21.0-beta) this surface speaks HTTPS — TLS 1.3 and nothing else — and without one it speaks plain HTTP, which a node that belongs to a cluster does only when told to. See Encryption in transit.

A store with no users is open and runs anything; the first DEFINE USER closes it, and from then on a request with no credential is answered 401 with WWW-Authenticate: Basic realm="TessariDB".

Sign in once

Basic works on every route, but it makes the node hash a password on every request — deliberately expensive, and capped at 24 at a time. Exchange it for a token instead:

$ curl -s -u ada -X POST http://127.0.0.1:8000/session
{"token":"5f3a…","expires_in":43200}
$ curl -s -H 'Authorization: Bearer 5f3a…' \
    --data-binary 'SELECT * FROM users;' http://127.0.0.1:8000/script

DELETE /session hands one back. A token expires after twelve hours, lives only in the memory of the node that issued it, and stops working the moment the account behind it changes — a rotated password, a changed role or a removed user kills every token that user held. Only a password may be exchanged for a token; presenting one to get another is refused, or expiry would mean nothing.

A WebSocket cannot carry a header, so /watch takes "token" in its first message instead — see watching.

POST /password changes your own and takes Basic only, never a token: it asks for the current password as a second proof, and a token is not one. Any role may use it, and it ends every token that user holds.

Running a script

$ curl -s --data-binary 'USE NAMESPACE prod; USE DATABASE orders; SELECT * FROM users;' \
    http://127.0.0.1:8000/script

The body is the script. With Content-Type: application/json the body is an envelope carrying a script and its parameters instead:

{ "script": "SELECT * FROM users WHERE name = $who;", "parameters": { "who": "ada" } }

The shape is decided by what the caller declares, never by sniffing the body. A server that guessed would classify a JSON-shaped script as an envelope, and the day somebody's script started with a brace it would break.

The routes

methodpathcredentialanswers
GET/healthnone by design200 / 503 json
GET/readynone by design200 / 503 json
GET/metricsnone by design; a session adds the topics it may read200 Prometheus text, 401 for a refused credential
POST/sessionpassword only200 json — a token
DELETE/sessiontoken200 json
POST/passwordpassword only200 json — your own, and ends your tokens
POST/scriptsession200 json
GET/backupsession200 octet-stream
GET/backup?from=<n>session200 octet-stream
GET/backup?as=statesession200 octet-stream — a snapshot
GET/backup?as=scriptsession200 TessariQL text — a script
GET/watchsession101 upgrade
GET/wirein the wire session101 upgrade — the wire protocol over a WebSocket
PUT/files/{ns}/{db}/{bucket}/{path…}session201 json
POST/files/{ns}/{db}/{bucket}/{path…}session201 json — the same write as PUT
GET/files/{ns}/{db}/{bucket}/{path…}session200 / 404 octet-stream
GET/files/{ns}/{db}/{bucket}session200 json — a listing
HEAD/files/{ns}/{db}/{bucket}/{path…}session200 / 404
DELETE/files/{ns}/{db}/{bucket}/{path…}session204 json
POST/series/{ns}/{db}/{series}session200 json — how many events landed
GET/vaultsession200 json — the store's seal status
POST/vault/unsealsession200 json — the body is the passphrase
POST/vault/sealsession200 json
POST/vault/passphrasesession200 json — the body is {"current": …, "new": …}
GET/vault/{ns}/{db}/{vault}session200 json — that vault's status and custody
POST/vault/{ns}/{db}/{vault}/unseal, /seal, /passphrasesession200 json — a vault with its own passphrase; one in the store's custody is 400

Four things worth knowing before you write a client

HEAD costs what GET costs. The node reads the whole object and throws the body away. Do not present a HEAD-backed exists() as a cheap probe — it is the same read with the answer discarded.

A trailing slash names a file, not a bucket. /files/ns/db/bucket lists the bucket; /files/ns/db/bucket/ reads a file called /. Normalise the slash away in your client, because list the bucket and read the file named / must not be one keystroke apart.

GET /backup on a store with no users is unauthenticated and returns a snapshot of the whole store. That is the open-store rule at its loudest rather than a defect, and it is the strongest practical argument for declaring an owner as the first thing a deployment does.

Do not probe /. The console owns it, and in a build without the console the same request is a 404. Use /health, which exists in every build.

Names and values

The {ns}, {db} and {bucket} segments must match [A-Za-z0-9_]+. They are names, interpolated into a statement, and that check is what makes the interpolation safe. The {path…} is a value, carried as a parameter — a file may be called anything, and slashes inside it are part of the name rather than directories. Same rule as everywhere else: a name is never supplied, a value always may be.

Appending events to a series

POST /series/{ns}/{db}/{series} writes a batch of events in one transaction and answers how many landed:

curl -s -H "Authorization: Bearer $TOKEN" \
    --data-binary "[{ 'sensor': 'boiler', 'v': 71.5, 'at': datetime '2026-09-29T10:00:00Z' }]" \
    http://127.0.0.1:8000/series/plant/metrics/readings
{"appended":1}

The body is one TessariQL value: an array of objects whose values are all literals. Nothing in it is evaluated, so a function call, a parameter or a path anywhere inside is a 400. It is not JSON, for the same reason a parameter is not: JSON cannot say a datetime, a decimal or a uuid. Each event goes through your session like a CREATE, so grants, tenancy, the series' checks and its rollups apply as they would to a script. One refused event fails the batch and nothing of it is kept. A table that is not a series and one that is not there both answer 404.

Appending is not idempotent: sent twice, a batch lands twice. Every client's append sends it once and leaves the retry to you. What the route is for is on Time-series.

Statuses

you getit means
400the request is wrong — the parser refused the script, a segment is not a name, or the store will not take a value; repeating it cannot succeed
401sign in — no credential against a closed store, or one refused
403you did sign in, and the role, tenancy or grant does not cover this
405wrong method for that route
409a conflict: run the transaction again (retry), or re-read, because the state the request assumed is not the state there is (conflict)
413a request body larger than 16 MiB — the wire protocol's frame size; refused before it is read when its length is declared
426/watch without the upgrade headers
404no such route — or a file that is not in the bucket
429a sign-in the node is not taking right now: that identity is being made to wait
502on a cluster, a request this node carried to the node that can answer it (a write it does not lead, say) and that hop failed — the body says why; retry, or send it to that node
500a substrate or decoding failure. Anything answering 500 is a bug, and the node logs it at error while a 404 is logged as ordinary traffic
503the request was fine and this node cannot serve it now — no leadership, no majority, no copy within the staleness bound, no backup folder — or /health or /ready saying no, or the node already answering as many requests as it will, with Retry-After: 1

401 and 403 must be kept apart by any client: 401 means sign in, 403 means signing in again will never help. Collapsing them makes a client retry credentials that cannot work, forever.

Every error body names its class. From node 0.30.0-beta the body is {"error": "<the store's words>", "code": "<class>"}, and the status is derived from the class, so the two cannot disagree: invalid 400, unauthenticated 401, forbidden 403, throttled 429, elsewhere 307, retry and conflict 409, unavailable 503, internal 500. The class is the same one the wire carries — the protocol page lists what each asks of the caller — and it is what to branch on; the words change between releases.

Watching

GET /watch upgrades to a WebSocket and the connection starts being told about changes instead of answering scripts. It is the same split as the Rust client's follow, for the same reason: one connection does one of the two things.

A browser cannot set a header on a WebSocket, so what authenticates the feed travels in its first message — "token" when there is one, and a "user" and "password" pair otherwise:

{ "namespace": "prod", "database": "orders", "from": 0, "token": "5f3a…" }

An Authorization header wins when the client can send one, because then the credential never travelled through a message body at all. Prefer the token: a password in a feed's opening message is a password in whatever logged it.

A feed over one table can be narrowed: "condition" is TessariQL text and "parameters" an object of names to TessariQL literals, read as /script reads its parameters:

{ "namespace": "prod", "database": "chat", "table": "msgs", "from": 0,
  "condition": "chat = $chat", "parameters": { "chat": "'a'" }, "token": "5f3a…" }

A record that stops matching arrives as "became": "removed". A feed that skipped changes sends { "progress": 41 } (with "cursor" on a split table): resume one past it.

The ceilings here are 64 KiB, and they are not the wire protocol's 16 MiB. One WebSocket frame the node will read is capped at 64 KiB, and so is one reassembled message — separately, because a message may legally arrive as many fragments, so bounding a frame alone would bound nothing. A client sending more than that over /watch is disconnected rather than answered, and the smaller number is the one to size against if you write to both surfaces.

The HTTP surface — TessariDB