Contents
Architecture
Mentat is one repository that builds two storage backends behind a single Datalog/EDN front-end. This chapter covers the front-end/backend split, the crate map, and which features live on which backend, then the internals of the PostgreSQL backend (storage layout, query compilation, transactions, pull).
Front-end / backend split
A query, transaction, or schema definition is EDN text. Both backends parse it with the same three shared crates:
edn— the EDN reader and the Datalog grammar. It produces aParsedQuery(find spec, where clauses,:inbindings,:with, rules) and parsed transaction entities, independent of any backend. It caps nesting depth before parsing so deeply nested input is rejected with an error rather than overflowing the stack.core-traits— the fundamental value model:ValueType,TypedValue,ValueTypeSet, and the type/value mappings.core(mentat_core) — conversions between EDN and the value model, reusable keyword constants, schema traits, and shared utilities.
Because a query is parsed by one grammar, it means the same thing on either backend. The backends diverge only below the parsed representation, where each turns it into storage-specific operations:
- The embedded backend algebrizes the parsed query into SQL over SQLite’s
datomstable (query-algebrizer→query-projector→query-sql) and transacts through its own SQLite transactor (db,transaction). - The PostgreSQL backend compiles the same parsed query into SQL over its
narrow per-type tables and executes it through PostgreSQL’s SPI
(
crates/pg/pg_mentat).
Crate map
crates/
├── edn/ shared: EDN reader + Datalog grammar
├── core-traits/ shared: ValueType, TypedValue, ValueTypeSet
├── core/ shared (mentat_core): EDN↔value conversions, schema traits
├── mino/ the mino-rs interpreter (mentat.store/* prims) + tests/corpus/
├── sqlite/ the embedded backend
│ ├── mentat/ the `mentat` crate: Store, Conn, InProgress, scripting glue
│ ├── db/ db-traits/ SQLite storage: bootstrap, transactor, attribute cache
│ ├── transaction/ in-progress transaction wrappers
│ ├── sql/ sql-traits/ SQL text generation for SQLite
│ ├── query-algebrizer{,-traits}/ parsed query → algebraic query
│ ├── query-projector{,-traits}/ algebraic query → output shaping
│ ├── query-pull{,-traits}/ pull execution
│ ├── query-sql/ algebraic query → SQL
│ ├── ffi/ C ABI over the embedded store (mentat_ffi)
│ └── cli/ embedded CLI (mentat_cli)
└── pg/
├── pg_mentat/ the pgrx extension (SQL functions, narrow tables, SPI)
└── mentatd/ HTTP/WebSocket server over tokio-postgres
A plain cargo build at the repo root builds only the SQLite side and the
shared front-end — no pg_config, libclang, or PostgreSQL required. The
extension builds with cargo pgrx from crates/pg/pg_mentat; mentatd builds
with cargo build -p mentatd.
Features by backend
| Feature | Embedded (mentat) |
PostgreSQL (pg_mentat) |
SQLite ext (mentat_sqlite_ext) |
DuckDB (mentat_duckdb) |
|---|---|---|---|---|
SQL functions edn_t / edn_q / edn_pull / edn_eval |
Rust API | yes | yes | yes |
Datalog q, pull, schema, transact |
yes | yes | yes | yes |
:db.fn/cas, :db/retractEntity |
yes | yes | yes | yes |
?added / history patterns in :where |
yes | yes | yes | yes |
Historical q (as-of / since) |
yes | yes | yes | yes |
:in scalar / collection / tuple / relation, mixed |
yes | yes | yes | yes |
mino scripting (edn_eval) |
mino feature |
script feature |
on by default | on by default |
C ABI (ffi) |
yes | — | — | — |
| Join Datalog results against native SQL tables | — | via JSONB | via json_each |
yes (table function) |
| Reactive subscriptions (LISTEN/NOTIFY) | — | yes | — | — |
| BM25 full-text, pgvector, pg_trgm, PostGIS, rum | — | yes | — | — |
| Materialized / virtual views, multi-store, excision | — | yes | — | — |
mentatd HTTP/WebSocket server |
— | yes | — | — |
The SQLite and DuckDB extensions embed the SQLite-backed store, so they support
whatever the embedded engine supports; each call takes the store’s file path
first. On the embedded Rust API, historical q is Store::q_once_as_of(tx, …) /
q_once_since(tx, …), and mixed :in inputs are built with
QueryInputs::merge. The SQL functions all take the same options JSON:
{"inputs": [...]}, {"asOf": tx}, {"since": tx}.
The DuckDB edn_q returns every column as VARCHAR for now; typed columns and
a DuckDB-native storage backend are planned (see duckdb-extension-plan.md).
The SQLite extension’s edn_q returns JSON text in pg_mentat’s
{"columns": [...], "results": [...]} shape.
PostgreSQL backend internals
pg_mentat implements the Datomic data model within PostgreSQL using pgrx (Rust PostgreSQL extension framework). The rest of this chapter describes its storage layout, query compilation pipeline, transaction processing, and pull architecture.
Storage Model
Narrow Per-Type Tables
pg_mentat stores datoms (facts) in nine type-specific narrow tables. Each table stores values of exactly one PostgreSQL native type, enabling the query optimizer to use type-appropriate comparison operators and indexes without casts.
| Table | Value Column Type | Datomic Type |
|---|---|---|
mentat.datoms_ref_new |
BIGINT |
:db.type/ref |
mentat.datoms_boolean_new |
BOOLEAN |
:db.type/boolean |
mentat.datoms_long_new |
BIGINT |
:db.type/long |
mentat.datoms_double_new |
DOUBLE PRECISION |
:db.type/double |
mentat.datoms_text_new |
TEXT |
:db.type/string |
mentat.datoms_keyword_new |
TEXT |
:db.type/keyword |
mentat.datoms_instant_new |
TIMESTAMPTZ |
:db.type/instant |
mentat.datoms_uuid_new |
UUID |
:db.type/uuid |
mentat.datoms_bytes_new |
BYTEA |
:db.type/bytes |
Each table has columns: e (entity), a (attribute entid), v (typed value), tx (transaction ID), added (boolean: assert or retract).
Compatibility View
A mentat.datoms view is provided as a compatibility shim over the nine narrow tables with INSTEAD OF triggers. This allows legacy code to INSERT INTO mentat.datoms or DELETE FROM mentat.datoms – the triggers route to the appropriate narrow table. New code should use the narrow tables directly.
Covering Indexes
Each narrow table has four covering indexes, mirroring Datomic’s index model:
| Index | Column Order | Use Case |
|---|---|---|
| EAVT | (e, a, v, tx) |
Entity lookup – “what do I know about entity E?” |
| AEVT | (a, e, v, tx) |
Attribute scan – “all entities with attribute A” |
| AVET | (a, v, e, tx) |
Value lookup – “who has name = ‘Alice’?” |
| VAET | (v, a, e, tx) |
Reverse ref – “who points to entity V?” (ref tables only) |
This is 4 indexes per table x 9 tables = 36 indexes total. The VAET index is only useful on datoms_ref_new but is maintained on all tables for uniformity.
Supporting Tables
| Table | Purpose |
|---|---|
mentat.schema |
Attribute definitions (entid, ident, value_type, cardinality, etc.) |
mentat.idents |
Keyword-to-entid bidirectional mapping |
mentat.partitions |
Entity ID allocation ranges per partition |
mentat.transactions |
Transaction log (tx ID, timestamp) |
mentat.cache_generation |
Cross-backend schema cache invalidation counter |
Query Compilation Pipeline
A Datalog query flows through four stages:
EDN text --> Parse --> Compile --> SQL text --> SPI Execute --> JSON result
Stage 1: EDN Parse
The edn crate (workspace member) parses the EDN query string into a ParsedQuery struct containing:
- find – find specification (relation, collection, tuple, scalar)
- where_clauses – pattern matches, predicates, function calls, not/or clauses
- in_bindings – input parameter bindings (scalar, collection, tuple, relation)
- with – additional grouping variables
- rules – named rule definitions
Stage 2: Datalog Compile
The query compiler (pg_mentat/src/functions/query.rs) transforms the parsed query into SQL:
- Schema resolution – resolve keyword attributes (
:person/name) to entid integers via the schema cache - Variable binding – assign SQL aliases to each pattern variable (
?e->t0.e) - Table selection – route each pattern to the correct narrow table based on the attribute’s value type
- Join generation – unify shared variables across patterns via
JOIN ... ON t0.e = t1.e - Predicate translation – convert Datalog predicates to SQL
WHEREconditions with type guards - NOT/OR compilation –
notbecomesNOT EXISTS (subquery),orbecomesUNION - Aggregate wrapping – wrap the base query in
GROUP BYwith aggregate functions - Find spec projection – shape the output (array of arrays, single value, etc.)
All string constants use parameterized bindings ($1, $2, …) via the SqlBuilder to prevent SQL injection.
Stage 3: SQL Execution
The generated SQL is executed through PostgreSQL’s SPI (Server Programming Interface). Results are collected into a serde_json::Value and returned as JSONB.
Stage 4: Result Shaping
The JSON result is shaped according to the find specification:
- Relation [:find ?x ?y] – array of arrays: [[1, "Alice"], [2, "Bob"]]
- Collection [:find [?x ...]] – flat array: [1, 2, 3]
- Tuple [:find [?x ?y]] – single array: [1, "Alice"]
- Scalar [:find ?x .] – single value: 1
Transaction Pipeline
Transaction processing (pg_mentat/src/functions/transact.rs) follows these steps:
EDN tx data --> Parse --> Schema detect --> Tempid allocate -->
Type route --> Upsert resolve --> Insert/Retract --> Commit
Steps in Detail
Parse – EDN transaction data is parsed into assertion/retraction operations (
:db/add,:db/retract, map forms,:db/retractEntity,:db.fn/cas)Schema detection – operations targeting schema attributes (
:db/ident,:db/valueType, etc.) are identified; these will update the schema table and invalidate cachesTransaction ID allocation – a new transaction ID is obtained from the
mentat.transactionssequenceTempid resolution – string tempids (
"tempid-1") are mapped to freshly allocated entity IDs from the partition sequenceLookup ref resolution – lookup refs like
[:person/email "alice@example.com"]are resolved to entity IDsUpsert handling – for attributes with
:db/unique :db.unique/identity, existing entities are found and reused rather than creating duplicatesType routing – each datom is inserted into the appropriate narrow table based on the attribute’s value type
CAS validation –
:db.fn/casoperations verify the current value matches the expected value before asserting the new valueCommit – the transaction record is written to
mentat.transactions; if schema changed, the cache generation counter is bumped
Speculative Transactions
mentat_with() wraps the transaction in a savepoint and rolls back after computing the result. This lets you see what a transaction would do without persisting it.
Pull Architecture
The pull API (pg_mentat/src/functions/pull.rs) implements Datomic’s declarative entity retrieval:
Pull pattern --> Parse --> Recursive fetch --> Cycle detection --> JSON assembly
Key implementation details:
- Recursive navigation – forward refs (
:person/friends) and reverse refs (:person/_friends) are followed to arbitrary depth - Cycle detection – a visited-entity set prevents infinite loops in cyclic reference graphs
- Bounded recursion –
{:person/friends 3}limits traversal depth - Component auto-expansion – attributes marked
:db/isComponent trueare automatically pulled recursively - Wildcard –
[*]retrieves all attributes for an entity - Default values –
(default :person/age 0)provides fallbacks for missing attributes - Limits –
(limit :person/email 5)caps cardinality-many results
Schema Cache
pg_mentat maintains a process-local LRU schema cache (SchemaCache) per store that maps attribute keywords to their metadata (entid, value type, cardinality, etc.). This avoids repeated schema table lookups during query compilation.
Cache invalidation uses a generation counter stored in mentat.cache_generation:
- After a schema-affecting transaction, the counter is bumped
- Before query compilation, the local cache checks its generation against the table
- If stale, the cache is reloaded from mentat.schema
This ensures cross-backend consistency when multiple PostgreSQL backends modify schema concurrently.
Multi-Store Architecture
pg_mentat supports multiple isolated stores within a single PostgreSQL database. Each store gets its own PostgreSQL schema (e.g., mentat_mystore) containing independent copies of all tables and indexes. The default store uses the mentat schema.
SELECT mentat.create_store('analytics');
SELECT mentat.t('analytics', '[{:db/ident :event/type ...}]');
SELECT mentat.q('analytics', '[:find ?e :where [?e :event/type "click"]]', '{}');