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 engines

Eleven engines over one substrate, what each one is asked with, and what serves it.

"One store" is a claim worth being precise about. TessariDB is not one engine that does several things loosely; it is several engines that share one substrate, one transaction and one language. This page names them.

The whole set

engineholdsasked withserved bywhere
Recordsdocuments — objects, arrays, nested treesCREATE SELECT UPDATE DELETEidentity, a value index, or a scanRecords
Graphedges between recordsRELATE, -> and <- traversal, bounded by DEPTHthe edge table's own index, or adjacency written beside the nodeGraphs
Full-textterms, produced by an analyzerMATCHES, search::score; ORDER BY FUSE (…) to rank by words and meaning or place togethera SEARCH indexFull-text search, Several orders at once
Vectorarrays of numbers, in the record — of a declared width where you want onevector::euclidean and friends, in ORDER BY; APPROXIMATE and EFFORT to ask for the index's own answera VECTOR proximity graphVectors
Geometryshapes on the sphereeight geo:: predicates, a distance to any shape, an area, and geo::cell for counting a mapa SPATIAL cell index — for predicates, nearest-first and radius readsGeometry
Key-valueone value per key, optionally expiring, in a space that may hold at most so manyGET SET DEL KEYS, EXPIRE TTL, INCR, SET … IFthe key itselfSpaces, Expiry, counters and locks
Filesbytes, in chunks, under a ceiling the bucket may declarePUT READthe bucket's metadata recordsFiles
Time-seriesevents ordered by when they happened, answered only within a declared retention, with rollups kept by the writesDEFINE SERIES … RETAIN … TIME at, time::bucket with GROUP BY and FILL, LATEST BY, ASOF JOIN, DEFINE ROLLUPthe event time in the key, so a late event lands in place and the floor is a positionTime-series
Vaultdeclared fields marked SECRET, sealed one key per recordDEFINE VAULT, REVEAL, ADD RECIPIENTthe record's own data key — not an indexVaults
Queuesordinary records, handed out one holder at a time under a hold that lapsesDEFINE QUEUE … TIMEOUT, CLAIM, RELEASEthe deadline the claim writes, which is an ordinary field of the recordQueues
Topicsmessages that are never changed, in commit order, at dense positionsDEFINE TOPIC, CREATE, READ FROM … FOR CONSUMERthe position, and each reader's stored positionTopics

None of those is a plug-in, a sidecar or a second process. Every one of them writes through the same transaction, so a record, its edge, its terms, its vector and its shape either all land or none of them do.

Seven of them change how a record is reached. The other four change something else, and that is exactly why they are the four declarations that do not stand for DEFINE TABLE and some clauses written out by hand: the vault changes what is stored, the series changes what the store will answer with, the queue changes who may reach a record, and until when, and the topic changes what may ever happen to a record once written — nothing. A claimed_until field on a plain table is a field and not a hold; a retain field is a field and not a floor. In all three the behaviour is the kind, which is why the kind is what the store keeps.

Which one you want

The table above says what each engine is. This one starts from the question you arrived with.

what you are trying to dowhat to reach forwhy that one
store an object and read it back by idRecords, no indexidentity is already the fastest path there is — a seek, not a search
find records by the value of a fieldRecords, a value indexan index changes what the read costs, never what it answers
find records by a word inside textFull-texta value index matches the whole field; MATCHES matches terms
rank text results by how well they matchFull-text, search::scorescoring needs a collection to measure a word's rarity against
rank several fields of several tables as one collectionFull-text, DEFINE SEARCHone analyzer and one set of statistics, each table's grants kept (One search over several tables)
find what is near something in spaceGeometrymetres on the sphere, and predicates that are exact at the boundary
find what is similar to somethingVectorsimilarity is distance between arrays of numbers, not a filter
follow relationships between recordsGraph, edges as recordsan edge is a record with its own index, so it carries data and can be queried like a row
walk a graph, where the cost that matters is the hopGraph, a declared graphadjacency sits beside the node, so a hop is one range read and does not grow with how many neighbours a node has
keep one value under one key, fastKey-value spaceno document tree to walk, and the key is the address
a session that times out, a rate-limit window, a lock that frees itselfKey-value, EXPIRE, INCR, SET … IF ABSENTthe expiry is in the key's version, so every node and every restart agrees on it
a cache that holds at most so many entriesKey-value, DEFINE SPACE … MAX nthe least recently written go first, in the commit, so every copy holds the same keys
publish events that several parts of the application read, each at its own paceTopicsa reader's position commits with what it writes, so an effect in this store happens once per message
rank by the words typed and what they meant, or and where it isFull-text with ORDER BY FUSE (…)fused by place in each order, never by adding a score to a distance
keep bytes that are not a document at allFiles bucketchunked storage, with the metadata as ordinary records
group events by hour, day or weekTime-series, time::bucketthe window is anchored to the epoch, so every process that asks for 1h agrees on where the hour starts
keep only the last thirty days of a table, and have reads say soTime-series, DEFINE SERIES … RETAIN 30dthe floor governs the answer; removing the bytes is a separate act, so a pass that lags costs storage and never correctness
hand a piece of work to exactly one worker at a timeQueuesthe hold lapses on its own, so a worker that dies returns its work without anything having to notice
keep a credential the database itself cannot read backVaultthe field is sealed before the record is encoded, so the index, the change feed, the replication log and every backup carry ciphertext

Two things that table is deliberately not saying.

These are not alternatives. One record can carry a text field, a vector, a shape and an edge at once, and one transaction writes all of them. The question is never which engine is this database but which access path answers this read — and a single statement may use several.

Only one of them trades exactness for speed, and it says so in the statement: a vector read is exact unless you write APPROXIMATE. The reasoning is below.

Declaring an engine — the route in

When an engine is what you are storing, the store is declared as one, and that is the shortest way in:

DEFINE GRAPH social;
DEFINE EDGE knows IN social FROM social TO social;

DEFINE VECTOR embeddings DIMENSION 768 DISTANCE cosine;

DEFINE GEO places;

DEFINE BUCKET avatars MAX 5242880;

UNSEAL VAULT WITH 'the operator passphrase';
DEFINE VAULT team;

DEFINE QUEUE jobs TIMEOUT 30s ATTEMPTS 5;

DEFINE TOPIC events RETAIN 7d;

DEFINE SERIES readings RETAIN 30d;

Each of those is written into directly. CREATE social:1 = { … }, CREATE embeddings:'intro' = { … }, CREATE places:'paris' = { … } — the word declares the store and the place its records live, so nothing else has to be declared first. That symmetry is the point: a declared engine you cannot write a record into is a label, not a store.

DEFINE VAULT, DEFINE QUEUE, DEFINE TOPIC and DEFINE SERIES are the exceptions to the paragraph below: none of them is a shorthand. What makes a vault a vault is a key minted at declaration time, and no combination of other statements mints one — it is also the only declaration that needs the store unsealed. What makes a queue a queue is that a claim writes a deadline the store honours, and what makes a series a series is that reads stop at a floor, and what makes a topic a topic is that a message is never changed and its position is decided at commit; none of those is a field you could declare, which is why the timeout and the retention are required with no default. Declaring one is the whole capability, and a value the store guessed would hand work over, or drop a record, at a moment nobody chose. See Vaults, Queues, Topics and Time-series.

None of the others is a new storage engine. Each stands for statements you could write by hand — DEFINE VECTOR for a collection, a field of a declared width and a vector index; DEFINE GEO for a collection, a required geometry field and a spatial index; DEFINE GRAPH for a graph and the collection its nodes live in — run through the same code the long spellings run through.

Existing tables can join a graph rather than being replaced by it: DEFINE TABLE company (name string) IN social makes a table you already have a node kind. That is an option, not the route in.

What the declaration adds is that the parts cannot come apart. A width with no index declares a shape nothing searches; an index with no width admits a row of the wrong shape and reports it as infinitely far from everything; neither without REQUIRED admits a record carrying no vector at all, which is legal in a table and is not a record of a vector store. Written by hand, three statements are three chances to be wrong, and the store cannot tell afterwards that they were meant as one thing.

The graph is the one that is more than a collection with an index on it. Its own nodes are ordinary records, and an attached table's membership is a clause — but an edge kind is not a table at all, and its entries are held beside the node in both directions rather than as records behind an index. A hop is a range read over one node's own prefix, so it does not grow with how many neighbours that node has — measured at degree 4 and degree 400 as the same single ask of the store underneath.

Each declared store answers for itself, and the report is the reason the declaration is worth having:

INFO FOR GRAPH social;
INFO FOR VECTOR embeddings;
INFO FOR GEO places;

INFO FOR VECTOR reports the width, the distance, and the recall the index was measured at — or NONE, because a figure derived from the build parameters would be one nobody checked wearing the name of one somebody did. INFO FOR GEO reports how many records its index offered against how many the exact predicate kept, which is the number that says whether the index is earning its place.

DROP GRAPH, DROP EDGE, DROP VECTOR and DROP GEO remove them, and the refusals are where the declaration earns its keep a second time. DROP VECTOR and DROP GEO refuse a table that is not one of those stores rather than dropping it, because the two words name different things even where they would remove the same rows — a DROP GEO that quietly removed an ordinary table would be a typo with the blast radius of a table. DROP GRAPH refuses while any table or edge kind still belongs to the graph, because dropping anyway would leave every member pointing at a graph the store no longer has, and that surfaces later as a walk that finds nothing rather than now. The details are on Graphs, Vectors, Geometry and Files.

The combination, and why AI is the case that needs it

Most workloads use one or two of these engines. An AI application reaches for most of them: retrieval takes four at once, and around it grow the rest — a space for the state of the tools the model calls and a cache of answers that cost money, a queue for the embedding pipeline, a topic for the events the rest of the product reacts to, places, relations, files and sealed credentials. Retrieval is the part worth naming specifically, because it is where the seam between systems is sharpest.

A chunk of text, the embedding it produced, the metadata you filter on and a link back to the document it came from are four different shapes. Kept in four systems — a vector database, a document store, a search index, and object storage for the originals — they are kept in step by a dual write that cannot be made atomic, and every failure between two of those writes leaves an embedding with no text or a chunk with no vector.

Here they are one record in one transaction:

what you needwhat it is here
the chunka record
its embeddingan array of numbers, a field of that record
what you filter onordinary fields, with ordinary indexes
where it came froman edge, or a file in a bucket

Which makes the two operations that matter ordinary ones. Writing is a single transaction that either lands whole or not at all — so there is no reconciliation job, because there is nothing to reconcile. Reading is one statement that can hold a nearest-neighbour ordering, a full-text match and a filter together, resolved by the store rather than intersected in your application: the front page opens with exactly that query.

Two properties that follow are easy to miss and hard to add later. A vector read is exact unless the statement says APPROXIMATE, so the ground truth you measure your own recall against comes from the same store rather than a second one you have to trust. And because provenance is an edge, which document did this answer come from is a traversal rather than a column convention that nothing enforces.

Reaching an engine from a statement

The other route, and the one the section above calls an option: an engine reached through a table that happens to hold the right field and the right index. It is the correct shape when the vectors, the shapes or the edges are one aspect of a record among several — a chunk with an embedding is still a chunk — and it is how the combination above is written.

There is no engine to select and no connector to configure. An engine is reached by the ordinary syntax that only it can answer, and the planner picks the index — so the whole set is visible in one script:

DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE library;
USE DATABASE library;

DEFINE TABLE chunks (
    embedding array,
    placed    geometry,
    at        datetime,
    team      string
);

DEFINE ANALYZER simple FILTERS lowercase, ascii;
DEFINE FIELD body ON chunks TYPE string ANALYZER simple;
DEFINE INDEX by_team ON chunks FIELDS team;
DEFINE INDEX by_body ON chunks FIELDS body SEARCH;
DEFINE INDEX by_where ON chunks FIELDS placed SPATIAL;
DEFINE INDEX by_embedding ON chunks FIELDS embedding VECTOR euclidean;

DEFINE COLLECTION documents;
DEFINE TABLE came_from EDGE;
DEFINE BUCKET originals;

Each engine then answers the syntax that belongs to it:

CREATE documents:1 = { title: 'handbook' };
CREATE chunks:1 = {
    body: 'the door opens outward',
    embedding: [0.1, 0.2, 0.3],
    placed: geometry { type: 'Point', coordinates: [13.4, 52.5] },
    at: datetime '2026-08-26T10:05:00Z',
    team: 'core'
};
RELATE chunks:1->came_from->documents:1;

SELECT * FROM chunks WHERE team = 'core';
SELECT * FROM chunks WHERE body MATCHES 'door';
SELECT body, search::score(body, 'door') AS rank FROM chunks WHERE body MATCHES 'door';
SELECT * FROM chunks ORDER BY vector::euclidean(embedding, [0.1, 0.2, 0.3]) LIMIT 5;
SELECT * FROM chunks WHERE geo::distance(placed, geometry { type: 'Point', coordinates: [13.4, 52.5] }) < 1000;
SELECT time::bucket(at, 1h) AS hour, count(*) AS n FROM chunks GROUP BY time::bucket(at, 1h);
SELECT * FROM chunks:1->came_from->documents;

Nothing above names an engine. MATCHES is the full-text engine because no other engine can answer it; vector::euclidean in an ORDER BY is the vector engine for the same reason; a geo:: predicate is the spatial one. The index decides the cost and never the answer — every one of those statements returns the same records with the index dropped, only slower, which is what makes adding an index a decision you can take later and reverse.

Which index serves a read is a question with an answer:

EXPLAIN SELECT * FROM chunks WHERE team = 'core';

And they compose, because they are clauses of one statement rather than four systems being intersected in your application:

SELECT body, search::score(body, 'door') AS rank
FROM chunks
WHERE team = 'core' AND body MATCHES 'door'
ORDER BY vector::euclidean(embedding, [0.1, 0.2, 0.3])
LIMIT 5;

The substrate underneath

TessariQL parse · plan · evaluate · authorize records transactions · snapshot isolation · indexes ordered kv byte keys in order · atomic batch · one contract in memory — the tests LSM on disk — the data
One contract at the bottom, and two implementations of it.

The lowest layer stores opaque byte keys in lexicographic order and answers four questions: read one key, scan a range, apply a batch of changes atomically, and refuse that batch if a stated precondition no longer holds. That is all.

It does not sequence, order or replay anything. Sequence numbers, the replication log, versioning and transaction isolation live above it, because the engine owns its own ordering and a backend inventing its own would compete with it.

There are two implementations of that one contract, and that is not redundancy. The in-memory one keeps the test suite fast enough to run on every change; the log-structured one is where the data actually lives. A contract with a single implementation is a description of that implementation — running the same conformance suite against both is what makes it a contract instead.

Records and transactions

The record layer turns ordered bytes into a record store with a declared isolation level: snapshot isolation, named in the transaction's documentation and in the error taxonomy rather than left to be inferred.

It is declared because it is a consequence of the layout rather than an accident of it. Versions are stored inline under each record, newest first — so a snapshot is one sequence number, and a read at that snapshot is a seek.

What each engine costs you to be exact about

The interesting part of a multi-model store is not that it has the models. It is what each one had to decide in order to be exact.

Full-text reads its analyzer from the schema, never from the index. That one placement is why adding a search index cannot change what MATCHES answers: a scan and an index analyse the same text the same way, by construction. Scoring is the other half and is a different kind of question — how much a word is worth depends on how often it occurs everywhere else, so a score is a property of the collection and is refused where there is no collection to measure against.

Geometry snaps positions at ingest to a fixed integer grid, and every predicate is then an integer determinant with an exact sign. That removes the reason a geospatial engine normally needs an adaptive-precision floating-point kernel, and it removes the tolerances that make float predicates non-transitive — the ones that make A touches B and B touches C disagree about A and C. A precision model was obligatory anyway: a store that keeps whatever float arrived cannot promise a lossless read-modify-write.

Vectors are arrays of numbers and not a value type of their own. A new value type would have needed an encoding, an ordering, a literal syntax and a migration, to buy nothing — an array of numbers already is a vector, and it is stored as one.

What a field may declare is the width: TYPE vector<768>. That is a declaration, not a value type, and the distinction is the whole of why it was worth adding. TYPE array is true of a 768-wide embedding and says nothing, so a 512-wide row sits legally beside it and the mistake surfaces only where the distance functions meet them — as a plausible ordering rather than a complaint, because a wrong-shaped vector is infinitely far from everything. The width moves that refusal to the write, where the cause is.

Files keep their bytes in a companion table nothing can name, and their metadata as ordinary records. So listing a bucket is a query, a file can be the target of an edge, and count(*) over a bucket counts files rather than chunks.

The one index that is allowed to change an answer

Everywhere above, an index changes what a read costs and never what it answers — value indexes, search indexes and spatial indexes all propose candidates that an exact check then decides.

The vector index cannot make that promise. It is a proximity graph, a search returns the neighbours a walk found, and a walk cannot show that it missed none. So the exception is written into the statement rather than hidden in the catalog: a read is exact unless it says APPROXIMATE.