Contents
Datomic Compatibility
pg_mentat implements the core Datomic data model and query language as a PostgreSQL extension. This document reflects the actual implementation status of the current codebase.
Overview
pg_mentat provides Datomic-compatible schema, transactions, and Datalog queries via SQL functions. The companion mentatd server exposes an HTTP API compatible with Datomic client libraries.
Transaction Operations
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
:db/add |
Yes | Yes | Complete |
:db/retract |
Yes | Yes | Complete |
:db/retractEntity |
Yes | Yes | Complete – cascades :db/isComponent children and retracts inbound refs (1.14.0); accepts a lookup ref |
:db.fn/cas |
Yes | Yes | Complete – compare-and-swap; accepts a lookup ref |
with (speculative) |
Yes | Yes | Complete – mentat_with() |
| Map notation | Yes | Yes | Complete |
| Tempids | Yes | Yes | Complete – a tempid used only as a ref value, never as the entity of a datom and not upserting, fails with :db.error/tempid-not-an-entity naming it, as in Datomic (1.19.0; it used to make an entity with no datoms). The reserved tempid "datomic.tx" names the transaction being committed (1.19.0); see The transaction tempid |
| Lookup refs | Yes | Yes | Complete – [:unique/attr v] (or (lookup-ref a v)) in entity position, :db/id, and as the value of a ref attribute; in map form, for a cardinality-many ref, [[:a/id 1] [:a/id 2]] is a list of lookup refs (see Lookup refs in a list of values). The embedded engine and DuckDB accept the vector form since 1.18.0 |
| Upsert (unique/identity) | Yes | Yes | Complete |
| Tx metadata (:db/txInstant) | Yes | Yes | Complete – annotate the transaction with "datomic.tx" (1.19.0) |
| Negative tempids | Yes | No | N/A – use string tempids |
:db/fn (database functions) |
Yes (Clojure) | Yes (mino) | pg_mentat 1.14.0 (script builds); embedded SQLite (mino feature) and the DuckDB extension in 1.15.0 – sandboxed mino (fn [db & args] ...) returning tx-data; see Transaction functions |
Lookup refs in a list of values
A lookup ref [:attr v] names the entity whose :attr is v, for any
:db/unique attribute (:db.unique/identity or :db.unique/value), in
:db/id, entity position, and as the value of a cardinality-one ref. The
embedded engine and DuckDB also take one in reverse notation
({:a/_ref [:attr v]}); pg_mentat has no reverse notation in transactions.
In list form, [:db/add e a v] takes one value, as in Datomic, so on a ref
attribute (cardinality-one or -many) a vector v is always a lookup ref, and
an error if it doesn’t resolve. To add several values, use several :db/adds
or map form.
The whole value of a cardinality-many ref attribute in map form is the one
ambiguous place: a two-element vector there can be one lookup ref or a list
of two values. Every backend reads it the way DataScript does: when the first
element is the ident of a :db.unique/identity attribute, the vector is one
lookup ref. Otherwise it is a list of values, and each vector in that list is
a lookup ref (any :db/unique attribute; one that doesn’t resolve is an
error).
;; :user/id is :db.unique/identity, :user/email is :db.unique/value
{:form/owners [:user/id "u1"]} ; one ref: the entity with :user/id "u1"
{:form/owners [[:user/id "u1"] [:user/id "u2"]]} ; two refs, one per lookup ref
{:form/owners [[:user/email "a@x"]]} ; one ref, by :user/email
{:db/id [:user/email "a@x"] :user/name "A"} ; :db/id may use either kind
;; :user/name is not unique
{:form/fields [:user/email :user/name]} ; two refs, to the two attribute entities
;; list form: one value
[:db/add "f" :form/owners [:user/email "a@x"]] ; one ref, by :user/email
[:db/add "f" :form/fields [:user/email :user/name]] ; error: a lookup ref that does not resolve
A plain string in that list is a tempid, so {:form/owners [:user/email "a@x"]}
is a ref to the :user/email attribute plus the tempid "a@x", which is used
only as a value and fails with :db.error/tempid-not-an-entity (since 1.19.0).
Write [[:user/email "a@x"]] for the lookup ref.
:db/ident is itself :db.unique/identity, so [:db/ident :user/name] is a
lookup ref to the entity named :user/name, not refs to :db/ident and
:user/name. The same goes for a list whose first element names any identity
attribute. To write two refs in that case, make the first one a lookup ref
(or use entids):
{:form/fields [[:db/ident :user/id] :user/name]} ; two refs: :user/id and :user/name
Re-asserting and renaming an ident
:db/ident is :db.unique/identity, so a map or list entity that names an
existing ident upserts into that ident’s entity (since 1.19.0 on pg_mentat;
1.18.0 and earlier moved the ident to a new entity, see
Operations). Re-running
a schema install or [{:db/ident :color/red :db/doc "red"}] changes
nothing but the asserted values.
Rename an ident by asserting a new one on the same entity, by entid or by the old ident, as Datomic documents:
[{:db/id :color/blue :db/ident :color/azure}]
[[:db/add :color/red :db/ident :color/scarlet]]
An attribute’s schema is renamed with it. Unlike Datomic, the old ident is
not kept: in Datomic “both the new ident and the old ident will refer to the
entity” until the old one is given to another entity. On pg_mentat and the
embedded engine the old ident is gone once the renaming transaction commits:
a write that uses it fails with ident-not-found, and a query that uses it
as a constant matches nothing. Move readers and writers to the new ident
when you rename.
An ident another entity holds can’t be asserted on a second entity in the same transaction that retracts it; retract it in one transaction, then assert it on the other.
The transaction tempid “datomic.tx”
The reserved tempid "datomic.tx" names the transaction being committed, as
in Datomic, on every backend (since 1.19.0; before, it was an ordinary tempid
and made a new entity). Use it to put metadata on the transaction, in map or
list form, or as a ref value:
[{:db/id "datomic.tx" :audit/user "bob"}
[:db/add "datomic.tx" :audit/source "import"]
{:person/name "Alice" :person/import "datomic.tx"}]
;; read it back with the data it wrote
[:find ?user :where [?e :person/name "Alice" ?tx] [?tx :audit/user ?user]]
The tempids map has "datomic.tx" (the tx id) only when the transaction used
it. The embedded engine and DuckDB also accept (transaction-tx) for the same
entity. A tx-function may return {:db/id "datomic.tx" ...}.
The transaction is a new entity and never upserts: asserting a
:db.unique/identity or :db.unique/value value another entity holds is
:db.error/unique-conflict, naming the attribute and value. An explicit
:db/txInstant on it replaces the transaction’s time (on pg_mentat in the
log, the current state and mentat.transactions); neither backend checks it
against earlier transactions or the clock, which Datomic does. On pg_mentat,
mentat.entid_collision_report() does not count a transaction’s own
annotations as entid collisions (see Operations).
Transaction functions
On every backend with the scripting interpreter – pg_mentat built with the
script feature, the embedded store built with mino, the SQLite extension,
and the DuckDB extension – an ident carrying :db/fn is a transaction
function. Its value is mino (a Clojure dialect) source for
(fn [db & args] ...) returning tx-data, which is spliced into the calling
transaction (and may itself invoke tx-functions, built-ins, or lookup refs):
SELECT edn_t($$[{:db/ident :acct/deposit
:db/fn "(fn [db id amt]
(let [bal (ffirst (mentat.store/q db
'[:find ?b :in $ ?id :where [?e :acct/id ?id] [?e :acct/balance ?b]]
id))]
[[:db/cas [:acct/id id] :acct/balance bal (+ bal amt)]]))"}]$$);
SELECT edn_t('[[:acct/deposit "A-1" 50]]');
The function sees the database as of the start of the transaction (as in
Datomic) and runs in the same sandbox as edn_eval: no filesystem or network,
bounded by mentat.script_max_steps / _heap_bytes / _depth, and
mentat.txfn_max_depth (default 16; MENTAT_TXFN_MAX_DEPTH on the embedded
and DuckDB backends) bounds tx-functions calling tx-functions. On PostgreSQL it
runs as the calling role. The embedded and DuckDB stores define the :db/fn
attribute the first time a transaction installs a function; pg_mentat
bootstraps it. Use :db.fn/cas in the returned tx-data for an
atomic read-modify-write, as above. On pg_mentat a tx-function invoked on a
named store (t('acme', ...), with('acme', ...)) is looked up in that
store, and its db reads that store (since 1.19.0; 1.18.0 refused it). On
pg_mentat a tx-function can’t open any other store or transact; see
Scripting.
Query Operations
Find Specifications
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
Relation [:find ?x ?y] |
Yes | Yes | Complete |
Collection [:find [?x ...]] |
Yes | Yes | Complete |
Tuple [:find [?x ?y]] |
Yes | Yes | Complete |
Scalar [:find ?x .] |
Yes | Yes | Complete |
Where Clauses
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
| Basic patterns | Yes | Yes | Complete |
| NOT clauses | Yes | Yes | Complete – NOT EXISTS subquery |
| NOT-JOIN | Yes | Yes | Complete |
| OR clauses | Yes | Yes | Complete – UNION |
| OR-JOIN | Yes | Yes | Complete |
Input Bindings
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
Scalar :in ?x |
Yes | Yes | Complete |
Collection :in [?x ...] |
Yes | Yes | Complete |
Tuple :in [?x ?y] |
Yes | Yes | Complete |
Relation :in [[?x ?y]] |
Yes | Yes | Complete |
Query Functions
| Function | Datomic | pg_mentat | Status |
|---|---|---|---|
get-else |
Yes | Yes | Complete – pg_mentat: a correlated subquery + COALESCE, usable in predicates; embedded SQLite/DuckDB: desugared to or-join (1.17.0). Rejects cardinality-many (as Datomic) |
missing? |
Yes | Yes | Complete – NOT EXISTS subquery (embedded: not, 1.17.0) |
ground |
Yes | Yes | Complete – constant binding |
get-some |
Yes | No | Not implemented |
tuple |
Yes | No | Not implemented |
untuple |
Yes | No | Not implemented |
Predicates
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
=, != |
Yes | Yes | Complete |
<, >, <=, >= |
Yes | Yes | Complete |
Arithmetic (+, -, *, /) |
Yes | Yes | Complete |
like / ilike |
N/A | Yes | Extension – PostgreSQL LIKE/ILIKE |
| Type-safety guard | Yes | Yes | Complete – mismatched types produce empty set |
Aggregates
| Aggregate | Datomic | pg_mentat | Status |
|---|---|---|---|
count |
Yes | Yes | Complete |
count-distinct |
Yes | Yes | Complete |
sum |
Yes | Yes | Complete |
min |
Yes | Yes | Complete |
max |
Yes | Yes | Complete |
avg |
Yes | Yes | Complete |
sample |
Yes | Yes | Complete |
median |
Yes | No | Not implemented |
variance |
Yes | No | Not implemented |
stddev |
Yes | No | Not implemented |
Rules
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
| Named rules | Yes | Yes | Complete |
| Recursive rules | Yes | Yes | Complete – WITH RECURSIVE CTE |
| Rule sets (multiple heads) | Yes | Yes | Complete |
| Rules with predicates | Yes | Yes | Complete |
Pull API
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
| Attribute selection | Yes | Yes | Complete |
Wildcard [*] |
Yes | Yes | Complete |
Reverse lookup [:_attr] |
Yes | Yes | Complete |
| Nested/map specs | Yes | Yes | Complete |
Recursion {:attr ...} |
Yes | Yes | Complete – cycle detection |
Bounded recursion {:attr N} |
Yes | Yes | Complete |
| Default values | Yes | Yes | Complete |
Rename (:as) |
Yes | Yes | Complete |
Limit (:limit N) |
Yes | Yes | Complete |
| Component auto-expand | Yes | Yes | Complete |
pull-many |
Yes | Yes | Complete |
| Namespace wildcard | Yes | No | Not implemented |
Schema
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
:db/ident |
Yes | Yes | Complete |
:db/valueType |
Yes | Yes | Complete (9 types) |
:db/cardinality |
Yes | Yes | Complete (one/many) |
:db/unique (value) |
Yes | Yes | Complete |
:db/unique (identity) |
Yes | Yes | Complete + upsert |
:db/index |
Yes | Yes | Complete |
:db/fulltext |
Yes | Yes | Complete – BM25 scoring |
:db/isComponent |
Yes | Yes | Complete – cascade retraction |
:db/noHistory |
Yes | Yes | Complete |
:db/doc |
Yes | Yes | Complete |
| Schema alteration | Yes | Yes | Complete |
Value Types
| Type | Datomic | pg_mentat | Storage |
|---|---|---|---|
:db.type/boolean |
Yes | Yes | BOOLEAN in datoms_boolean_new |
:db.type/long |
Yes | Yes | BIGINT in datoms_long_new |
:db.type/double |
Yes | Yes | DOUBLE PRECISION in datoms_double_new |
:db.type/string |
Yes | Yes | TEXT in datoms_text_new |
:db.type/keyword |
Yes | Yes | TEXT in datoms_keyword_new |
:db.type/ref |
Yes | Yes | BIGINT in datoms_ref_new |
:db.type/instant |
Yes | Yes | TIMESTAMPTZ in datoms_instant_new |
:db.type/uuid |
Yes | Yes | UUID in datoms_uuid_new |
:db.type/bytes |
Yes | Yes | BYTEA in datoms_bytes_new |
:db.type/bigint |
Yes | No | Not implemented |
:db.type/bigdec |
Yes | No | Not implemented |
:db.type/uri |
Yes | No | Not Planned – use string |
:db.type/tuple |
Yes | No | Not Planned – Datomic Cloud only |
Time-Travel
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
as-of |
Yes | Yes | Complete – mentat_as_of() |
since |
Yes | Yes | Complete – mentat_since() |
history |
Yes | Yes | Complete – mentat_history() |
tx-range |
Yes | Yes | Complete – mentat_tx_range() |
| Basis-t | Yes | Yes | Complete – MAX(tx) query |
Excision
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
| Entity excision | Yes | Yes | Complete – mentat_excise() |
| Partition gating | Yes | Yes | Complete – allow_excision flag |
| Schema protection | Yes | Yes | Complete – entid < 10000 blocked |
| Dangling ref check | Yes | Yes | Complete |
| Excision log | N/A | Yes | Extension – audit trail |
Subscriptions / tx-report-queue
| Feature | Datomic | pg_mentat | Status |
|---|---|---|---|
| tx-report-queue | Yes | Yes | Complete – LISTEN/NOTIFY triggers |
| Subscribe by query | No | Yes | Extension – mentat_subscribe() |
| Unsubscribe | Yes | Yes | Complete – mentat_unsubscribe() |
mentatd HTTP Protocol
| Feature | Datomic Client | mentatd | Status |
|---|---|---|---|
| list-databases | Yes | Yes | Complete |
| create-database | Yes | Yes | Complete |
| delete-database | Yes | Yes | Complete |
| connect | Yes | Yes | Complete |
| transact | Yes | Yes | Complete |
| query | Yes | Yes | Complete |
| pull | Yes | Yes | Complete |
| entity | Yes | Yes | Complete |
| EDN serialization | Yes | Yes | Complete |
| Transit+JSON | Yes | Yes | Complete |
| CORS | N/A | Yes | Complete |
| HTTP/2 | Yes | No | Not implemented |
| Streaming responses | Yes | No | Not implemented |
Known Incompatibilities
| Feature | Reason | Workaround |
|---|---|---|
:db/fn body language |
Datomic fns are JVM Clojure; pg_mentat runs sandboxed mino | Port the body (same contract: db + args -> tx-data) |
| Peer mode | Architecture incompatible (embedded JVM) | Use mentatd HTTP API |
| Datomic Analytics | Proprietary | PostgreSQL analytics tools |
| Negative tempids | Not supported | Use string tempids |
| Peer cache | No equivalent | Application-layer caching |
Migration from Datomic
Schema definitions transfer directly. Remove #db/id partition references:
;; Datomic
[{:db/id #db/id[:db.part/db]
:db/ident :person/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db.install/_attribute :db.part/db}]
;; pg_mentat (same minus partition boilerplate)
[{:db/ident :person/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}]
Queries work unchanged. Transactions work unchanged. Pull patterns work unchanged.