Contents
mentat_sqlite_ext: mentat as a SQLite loadable extension
This crate exposes the embedded mentat Datalog engine as SQL functions in any
SQLite host (the sqlite3 CLI, Python’s sqlite3, an app linking libsqlite3).
The function names match the DuckDB extension (edn_t, edn_q, edn_pull,
edn_eval). The JSON shapes follow pg_mentat’s edn_q and edn_pull.
.load ./target/release/libmentat_sqlite -- or: SELECT load_extension('…/libmentat_sqlite');
SELECT edn_t('/tmp/people.db', '[{:db/ident :person/name :db/valueType :db.type/string
:db/cardinality :db.cardinality/one}]');
SELECT edn_t('/tmp/people.db', '[{:person/name "Alice"}]');
SELECT edn_q('/tmp/people.db', '[:find ?e ?n :where [?e :person/name ?n]]', NULL);
-- {"columns":["?e","?n"],"results":[[65537,"Alice"]],"result":[[65537,"Alice"]]}
Build
cargo build -p mentat_sqlite_ext # target/debug/libmentat_sqlite.so
cargo build -p mentat_sqlite_ext --release # target/release/libmentat_sqlite.so
cargo test -p mentat_sqlite_ext # pure-Rust tests (inputs contract, shapes)
crates/sqlite/ext/test/smoke.sh # real sqlite3 CLI; EXT=/SQLITE3= to override
The crate is a workspace member, but it is not a default-member, so a
plain root cargo build / cargo test never builds it. The root Cargo.toml
lists the crates/sqlite/* default members explicitly instead of using a glob
for this reason. workspace.exclude would also keep it out of the default
build, but then -p mentat_sqlite_ext would stop working from the root and
the crate would have its own Cargo.lock. The crate enables mentat/mino,
and Cargo unifies features. If it were a default member, every root build
would compile the default mentat with scripting on.
The entrypoint is sqlite3_mentatsqlite_init. SQLite derives that name from
the file name libmentat_sqlite.so (it drops lib and non-letters), so
.load needs no second argument. It is the only symbol the .so exports
(nm -D --defined-only prints just that one line).
The host SQLite must be 3.30.0 or newer, because the functions are registered
with SQLITE_DIRECTONLY. The extension checks the version at load time and
fails with an error message on older hosts.
Functions
The first argument is always the path of mentat’s store file, as in the
DuckDB extension. That store is independent of whatever database the host
has open. '' opens an in-memory store that exists only for that one call.
If any argument is SQL NULL, the result is NULL.
| Function | Returns |
|---|---|
edn_t(db_path TEXT, edn TEXT) |
JSON tx-report {"tx_id":N,"tx_instant":"RFC3339","tempids":{"a":N,…}} (same as DuckDB edn_t) |
edn_q(db_path TEXT, query TEXT, opts TEXT) |
JSON result. The shape depends on the find spec (below). |
edn_pull(db_path TEXT, pattern TEXT, entity INTEGER) |
JSON map with ":ns/attr" keys and ":db/id" (pg_mentat shape) |
edn_eval(db_path TEXT, script TEXT) |
EDN text (pr-str) of the script’s last form |
The shapes of edn_q results follow pg_mentat’s format_find_response:
| Find spec | JSON |
|---|---|
relation [:find ?a ?b …] |
{"columns":["?a","?b"],"results":[[…],…],"result":[[…],…]} |
relation, single aggregate [:find (count ?e) …] |
{"result": N} |
collection [:find [?a ...] …] |
{"result":[…]} |
tuple [:find [?a ?b] …] |
{"result":[a,b]} or {"result":null} |
scalar [:find ?a . …] |
{"result":v} or {"result":null} |
The values in edn_q results use pg_mentat’s edn_q encoding:
- A ref is a plain integer.
- A keyword is a string such as
":ns/name". - An instant is an ISO-8601 UTC string (
…Z). - A uuid is a hyphenated string.
- Bytes are a hex string.
- A
(pull ?e …)column nests as a JSON object.
edn_pull values follow pg_mentat’s edn_pull:
- A ref is
{":db/id":N}. - An instant is epoch microseconds.
- A cardinality-many attribute is an array.
To use a result from SQL, pass it to json_extract, json_each, or ->>.
For example, this joins a query result against a native table:
SELECT a.age, json_extract(m.value, '$[1]') AS name
FROM json_each(edn_q('/tmp/people.db', '[:find ?e ?n :where [?e :person/name ?n]]', NULL), '$.results') m
JOIN ages a ON a.name = json_extract(m.value, '$[1]');
edn_q is a scalar function, so it returns one value per call. A
table-valued version would need a virtual-table module, and this crate does
not have one yet.
edn_q options: the inputs contract
opts is a JSON object. SQL NULL, '', null, and {} all mean “no
options”. The DuckDB extension accepts the same object. The keys are a subset
of pg_mentat’s.
| Key | Meaning |
|---|---|
"inputs": [...] |
Positional values, one per :in binding form. Source vars such as $ are not binding forms. |
"asOf": T |
Query the database as of tx T (Store::q_once_as_of). |
"since": T |
Query only the datoms added after tx T (Store::q_once_since). Usually combined with a history pattern [?e ?a ?v ?tx ?added]. |
These cases are SQLite errors:
optsis not valid JSON, or is not an object.optshas an unknown key.asOfandsinceare both set.- The number of
inputsdiffers from the number of:inbindings.
Each inputs element is bound according to its :in form:
?x(scalar): the value itself.[?x ...](collection): a JSON array of values.[?a ?b](tuple): one JSON array.[[?a ?b]](relation): an array of arrays._placeholders are skipped.
JSON values are converted to mentat types the way pg_mentat’s bind_input_value does:
| JSON | TypedValue |
|---|---|
| integer | Long. It becomes Ref when the variable is in an entity or tx position, or in the value position of a :db.type/ref attribute. |
| float | Double |
true / false |
Boolean |
":ns/name" (string starting with :) |
Keyword |
| other string | String |
null, object |
error |
A query can bind either scalar inputs or one collection/tuple/relation input,
but not both. QueryInputs cannot merge the two kinds yet. The DuckDB
extension has the same limit.
Store cache
edn_t, edn_q and edn_pull reuse an open store per db_path (canonical
path + inode), one per host thread, instead of opening the store on every call.
Before each call the cached store compares its last tx with the tx high-water
mark persisted in the file (one primary-key read); if another connection or
process has committed since, the store is reopened, so it never sees a stale
schema or hands out an entid that is already taken. Writes make that check
inside their BEGIN IMMEDIATE. A call that fails drops its store.
MENTAT_STORE_CACHE=N sets the stores kept per thread (default 16, least
recently used evicted); 0 opens per call. edn_eval is not cached. The
cache holds file handles open: a store file deleted while cached stays
allocated on disk until the store is evicted or the host exits.
edn_eval and the store path
edn_eval builds a mentat::script::Interpreter::with_default_path(db_path).
With that interpreter, calling (mentat.store/open) with no arguments opens
db_path. The interpreter is sandboxed (mino_rs::Interpreter::sandboxed()),
so slurp, spit, and the other filesystem prims are unbound. Each eval is
also limited to 10M steps, a 64 MiB heap, and a call depth of 1000.
SELECT edn_eval('/tmp/people.db',
'(def c (mentat.store/open)) (mentat.store/transact c [{:person/name "Carol"}])');
Registration flags and safety
All four functions are registered with SQLITE_UTF8 | SQLITE_DIRECTONLY:
- They are not
SQLITE_DETERMINISTIC, because the store can change between calls.edn_tandedn_evalwrite, andedn_q/edn_pullread mutable state. - They are
SQLITE_DIRECTONLYbecause they open files. They can be called from top-level SQL, but not from views, triggers, CHECK constraints, or generated columns in an untrusted schema. Such a call fails with “unsafe use of edn_q”. - Each function body runs under
std::panic::catch_unwind. An error or a panic becomessqlite3_result_error, so a Rust panic never unwinds into C. - A
db_paththat names the host’s ownmaindatabase file is rejected. See the next section.
Two SQLites: why this design
A loadable extension has to call SQLite through the host’s
sqlite3_api_routines table. The mentat engine, however, depends on rusqlite
with features = ["limits", "bundled"] and opens its own store file.
Cargo unifies features across a build, so only one libsqlite3-sys exists in
this crate’s graph.
Option (b), rusqlite loadable_extension for the host plus a bundled SQLite
for the engine, does not work. This was tested on EC2 with a scratch cdylib:
rusqlite = { features = ["loadable_extension", "functions"] } next to
mentat (which enables bundled), and one function that opens a mentat
store.
- The build succeeds, but the bundled SQLite is gone. Cargo unifies
features, so the one
libsqlite3-sysgets bothbundledandloadable_extension, andloadable_extensionwins. Its build script takes thebuild_linkedpath, so the bundledsqlite3.cis never compiled.nmfinds nosqlite3_open_v2anywhere in the.so. Everysqlite3_*call, including the engine’s, becomes a stub that calls through the host’s API pointer. - At runtime, loading it crashed the host. The
.loadstep failed inside rusqlite’sextension_init2with the panicSQLite API not initialized or SQLite feature omitted. That panic happens inside a no-unwind extern fn, so it aborted thesqlite3process (core dump). - Outside a host, the engine cannot run. A plain
cargo testthat callsStore::openpanics with the same message, because there is no API pointer. - Even if loading worked, the engine would depend on the host’s SQLite version and compile options (the store needs FTS4, for example).
- There is no way to add a separately named second copy.
libsqlite3-sysdeclareslinks = "sqlite3", and Cargo allows only one such package per dependency graph.
Option (a) is what this crate does. It never depends on rusqlite’s
loadable_extension. src/lib.rs reads the twelve C API slots it needs
directly from the raw sqlite3_api_routines pointer, by fixed index. The
struct is append-only, and every slot used here exists since SQLite 3.7.16 (below the 3.30 floor checked at load).
The engine keeps its own statically bundled SQLite (rusqlite bundled,
currently 3.53) for the store. rustc links a cdylib with a version script
that exports only #[no_mangle] items, so the bundled sqlite3_* symbols
are local (nm shows them as t, not T). They never interpose on the
host’s symbols, and the .so has no undefined sqlite3_* imports. The
DuckDB extension embeds the store in the same way.
The cost is two SQLite library copies in one process. POSIX advisory locks
belong to the process, and two copies of SQLite do not share their
lock bookkeeping, so they must not open the same file
(howtocorrupt §2.2.1). This is why
the functions reject a db_path that names the host’s main database.
Limitation: databases added with ATTACH are not checked. Keep the
mentat store in its own file. Several edn_* calls on the same store are
safe, because they all go through the one bundled copy.