# Design notes: the Datomic-review issues (#325–#332) Each entry: what it is, the decision taken, the trade-offs, and what's left. Issues #329, #331, #332 (core) shipped in 1.13.0; the rest land in 1.14.0. --- ## #325 — user-defined transaction functions (PostgreSQL, `script` builds) **What.** An ident carrying `:db/fn` (bootstrap attribute entid 20, a string holding mino source `(fn [db & args] ...)`) is a transaction function. A transaction invokes it as `[:my/fn arg ...]`; it returns tx-data that is spliced into the same transaction. **Decision.** Expand invocations *before* the transactor's two passes (`functions/txfn.rs::expand_user_tx_fns`), recursively, in one sandboxed interpreter per transaction. The expansion is plain tx-data, so tempids, upserts, built-ins (`:db.fn/cas`, `:db/retractEntity`) and cardinality are handled by the existing code unchanged. Full design in `325-user-defined-transaction-functions.md`. | Trade-off | Chosen | Alternative | Why | |---|---|---|---| | Language | mino (sandboxed Clojure dialect) | JVM Clojure | No JVM in the backend; mino is the reviewed in-backend runner. Bodies need a mechanical rewrite from Datomic `:db/fn`. | | Where expansion happens | Pre-pass over the tx vector | Inside the transactor's entity loop (work-queue) | The loop iterates a fixed vector; a pre-pass needed no transactor restructuring and can't disturb tempid/upsert ordering. | | `db` the fn sees | Basis at the start of the tx | In-progress db including earlier datoms in the same tx | Matches Datomic, and avoids exposing half-applied state. A fn that needs an atomic read-modify-write uses `:db.fn/cas` in its output. | | Storage of the fn | `:db/fn` string datom | Opaque/compiled blob | Transparent (pull, pg_dump), versioned in history like any datom. Re-parsed per tx; cheap. | | Recursion bound | `mentat.txfn_max_depth` (16) + the `mentat.script_max_*` limits | Unbounded | A self-calling fn must fail cleanly, not loop. | | Availability | `script` feature only | Always on | The interpreter is behind `script`; default builds pay nothing. | **Security.** Same sandbox and limits as `edn_eval` (no filesystem/network prims; steps/heap/depth GUCs; interrupt hook); runs as the calling role; never `SECURITY DEFINER`. Installing a fn is transacting a string, i.e. the same trust as defining any attribute. **Not done (yet).** The embedded SQLite and DuckDB backends: they have the same `ScriptBackend` seam, so the same pre-pass could run there; tracked as a follow-up. A role gate on *installing* `:db/fn` (only if someone asks). --- ## #326 — `:db/retractEntity` retracts inbound references **What.** Datomic's retractEntity retracts every datom whose *value* is a ref to the entity, and cascades through `:db/isComponent`. All three backends did the component cascade but left inbound refs dangling. **Decision.** Retract `[?referrer ?a e]` for every live ref datom pointing at `e` (pg: `current_datoms` where `v_ref = e`; SQLite: `datoms` via the VAET index; DuckDB: the `v_i` mirror column). Only the ref datom goes; the referring entity survives. New `MentatStoring::referrers` on SQLite/DuckDB. | Trade-off | Chosen | Alternative | |---|---|---| | Behavior change | Always (Datomic semantics) | Opt-in flag | It's a correctness fix to match the documented model; a flag would keep the surprising default. | | Cascade referrers? | No — retract only the ref datom | Delete referring entities | Datomic doesn't; it would destroy unrelated data. | | Read source (pg) | Current-state projection | Append-only log `WHERE added` | The log keeps superseded assertions; reading it re-retracts history (found while testing; the outbound read moved to the projection too). | **Cost.** One indexed lookup per retracted entity (VAET / `v_ref` index). History is unchanged in shape: the extra retractions are ordinary datoms. --- ## #327 — lookup refs in `:db.fn/cas` / `:db/retractEntity` **What.** Datomic accepts `[:unique/attr value]` wherever an entity id goes. **State.** PostgreSQL already did (1.13.0 added a regression test). The embedded SQLite (and DuckDB, which shares the transactor) rejected it. **Decision.** `resolve_tx_fn_entid` resolves a lookup ref through the same `resolve_avs` the transactor uses for ordinary lookup refs; the attribute must be `:db/unique` and the entity must exist (a tx-fn can't upsert). | Trade-off | Chosen | Alternative | |---|---|---| | Missing entity | Error | Allocate (upsert) | cas/retractEntity of a non-existent entity is meaningless; erroring is what pg_mentat does. | --- ## #328 — scripting `q` against an as-of / since db **What.** `(mentat.store/q (mentat.store/as-of db t) query)` errored on the embedded backend ("Mentat has no as-of query rewriting"). **Finding.** The refusal was stale: the embedded engine has had temporal Datalog (`Store::q_once_as_of` / `q_once_since`) since 1.8.0, DuckDB's backend already used it, and pg passes `asOf`/`since` to `edn_q`. **Decision.** Route an as-of/since db to the temporal query entry points, with or without `:in` inputs. A shared model test now pins it on all three backends. | Trade-off | Chosen | Alternative | |---|---|---| | `pull` on a historical db (SQLite) | Still refused | Reconstruct via datoms | Separate change; `entity`/`read`/`datoms` already work on a historical basis. | --- ## #330 — `mentat_explain` / `mentat_query_sql` / `query_view` ignore collection `:in` **What.** These compiled the query through a wrapper that passed *no* enriched (collection / tuple / relation) bindings, so a `[?x ...]` input was silently dropped: the SQL had no filter and EXPLAIN showed a plan edn_q never runs. **Decision.** Build the enriched bindings exactly as the executing path does (`parse_input_bindings_v2`) in all three entry points, and delete the binding-dropping wrapper so it can't recur. | Trade-off | Chosen | Alternative | |---|---|---| | Missing declared input | Error (same as edn_q) | Silently unfiltered SQL | The old behavior hid the bug. | --- ## #332 follow-up — `(bm25-match ...)` and `(turbovec-near ...)` where-fns **What.** 1.13.0 shipped the pg_fts / pg_turbovec helper functions; queries couldn't use the indexes from Datalog. **Decision.** - `[(bm25-match $ :attr "query" k) [[?e ?score]]]` → a derived table over `datoms_text_new` with `to_ftsdoc(lang, v) @@@ to_ftsquery(lang, q)` and `ORDER BY ... <=> ...` (BM25, served by the pg_fts index), `LIMIT k`. - `[(turbovec-near $ :attr "[...]" k [:cosine|:l2|:inner|:l1]) [[?e ?dist]]]` → top-K from the per-attribute `turbovec.vector` aux table, using schema-qualified `OPERATOR(turbovec.)`. | Trade-off | Chosen | Alternative | |---|---|---| | bm25 attr/store predicate | Inlined integer literals | Bind parameters | `create_fts_index` builds a **partial** index (`WHERE a = AND store_id = 0 AND added`); the planner only uses it if the query predicate provably implies that, which a parameter doesn't. Both values are integers we resolve, never user text. | | Score direction | Raw `<=>` (smaller = better), as pg_fts orders | Negate to "higher is better" | Matches pg_fts and pgvector-style distance semantics already used by `vector-near`. | | Vector dimension | `CHECK (vector_dims(v) = N)` | `turbovec.vector(N)` typmod | pg_turbovec's type takes no typmod. Note pg_turbovec also requires `dim % 8 == 0` to build an index. |