Skip to main content

corium_tx/
lib.rs

1//! Pure transaction expansion, entity resolution, and validation.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use corium_core::{Cardinality, Datom, EntityId, Partition, Unique, Value};
6use corium_db::{Db, FIRST_USER_ID, bootstrap};
7use thiserror::Error;
8
9/// A temporary entity identifier scoped to one transaction.
10pub type TempId = String;
11
12/// The reserved tempid naming the transaction's own entity.
13///
14/// Asserting against it attaches metadata to the transaction — the audit
15/// trail's who/why alongside the engine's `:db/txInstant` when. Corium spells
16/// it as Datomic does so ported transaction data works unchanged; the EDN
17/// boundary also accepts `:db/current-tx` in entity position.
18pub const TX_TEMPID: &str = "datomic.tx";
19
20/// An entity position accepted by transaction operations.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum EntityRef {
23    /// A concrete entity id.
24    Id(EntityId),
25    /// A transaction-local identifier.
26    Temp(TempId),
27    /// A unique attribute/value lookup.
28    Lookup(EntityId, Value),
29}
30
31/// A transaction operation after boundary conversion.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum TxOp {
34    /// Assert a fact.
35    Add(EntityRef, EntityId, Value),
36    /// Retract a fact.
37    Retract(EntityRef, EntityId, Value),
38    /// Compare and swap a cardinality-one value.
39    Cas(EntityRef, EntityId, Option<Value>, Value),
40    /// Recursively retract an entity and its component children.
41    RetractEntity(EntityRef),
42}
43
44/// A map-form entity; each `(attribute, values)` entry expands to additions.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct EntityMap {
47    /// Entity position.
48    pub entity: EntityRef,
49    /// Attribute values.
50    pub attributes: Vec<(EntityId, Vec<Value>)>,
51}
52
53/// Transaction input supporting list and map forms.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum TxItem {
56    /// List-form operation.
57    Op(TxOp),
58    /// Map-form entity.
59    Map(EntityMap),
60}
61
62/// Successfully prepared transaction.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct PreparedTx {
65    /// Resolved datoms.
66    pub datoms: Vec<Datom>,
67    /// Allocations/upserts for caller tempids.
68    pub tempids: BTreeMap<TempId, EntityId>,
69}
70
71/// Transaction validation error.
72#[derive(Debug, Error, Eq, PartialEq)]
73pub enum TxError {
74    /// Attribute is absent from schema.
75    #[error("unknown attribute {0:?}")]
76    UnknownAttribute(EntityId),
77    /// Value does not match attribute type.
78    #[error("value has wrong type for attribute {0:?}")]
79    TypeMismatch(EntityId),
80    /// A lookup ref did not resolve.
81    #[error("lookup ref did not resolve")]
82    LookupNotFound,
83    /// Lookup refs require unique attributes.
84    #[error("lookup attribute is not unique")]
85    LookupNotUnique,
86    /// A uniqueness constraint would be violated.
87    #[error("unique value conflict")]
88    UniqueConflict,
89    /// CAS old value did not match.
90    #[error("compare-and-swap failed")]
91    CasFailed,
92    /// CAS is only valid for cardinality one.
93    #[error("compare-and-swap requires cardinality one")]
94    CasCardinality,
95    /// Transaction data asserted a `:db/txInstant` that does not advance the
96    /// transaction clock. Instants are monotone by construction, so a
97    /// backdated commit would break the ordering `as-of` an instant relies on.
98    #[error("supplied :db/txInstant {supplied} is not after the last commit's {last}")]
99    TxInstantNotMonotonic {
100        /// Instant asserted by the transaction data.
101        supplied: i64,
102        /// Instant of the previous commit.
103        last: i64,
104    },
105    /// `:db/txInstant` was retracted, changed, attached to another entity, or
106    /// supplied more than once. It is engine-owned data except for one
107    /// explicit assertion on the transaction currently being prepared.
108    #[error(":db/txInstant may only be added once to the current transaction entity")]
109    InvalidTxInstantOperation,
110}
111
112/// Expands and validates transaction input against `db`.
113///
114/// `tx` is the already allocated transaction entity id. Allocation begins at
115/// `next_user_sequence`, making the function deterministic and easy to model-test.
116///
117/// # Errors
118///
119/// Returns [`TxError`] when entity resolution, schema validation, uniqueness,
120/// or a built-in operation fails.
121#[allow(clippy::too_many_lines)]
122pub fn prepare(
123    db: &Db,
124    items: impl IntoIterator<Item = TxItem>,
125    tx: EntityId,
126    next_user_sequence: u64,
127) -> Result<PreparedTx, TxError> {
128    let mut ops = Vec::new();
129    for item in items {
130        match item {
131            TxItem::Op(op) => ops.push(op),
132            TxItem::Map(map) => {
133                for (a, values) in map.attributes {
134                    for value in values {
135                        ops.push(TxOp::Add(map.entity.clone(), a, value));
136                    }
137                }
138            }
139        }
140    }
141    let mut tempids = BTreeMap::new();
142    // Identity assertions unify a tempid with an existing entity before allocation.
143    for op in &ops {
144        if let TxOp::Add(EntityRef::Temp(temp), a, value) = op
145            && temp != TX_TEMPID
146            && db.schema().get(*a).and_then(|x| x.unique) == Some(Unique::Identity)
147            && let Some(e) = db.lookup(*a, value)
148        {
149            tempids.insert(temp.clone(), e);
150        }
151    }
152    let mut next = next_user_sequence.max(FIRST_USER_ID);
153    for op in &ops {
154        let entity = match op {
155            TxOp::Add(e, ..) | TxOp::Retract(e, ..) | TxOp::Cas(e, ..) | TxOp::RetractEntity(e) => {
156                e
157            }
158        };
159        if let EntityRef::Temp(temp) = entity {
160            // The transaction's own entity is already allocated; metadata
161            // assertions resolve to it rather than to a fresh id.
162            if temp == TX_TEMPID {
163                tempids.insert(temp.clone(), tx);
164                continue;
165            }
166            tempids.entry(temp.clone()).or_insert_with(|| {
167                let e = EntityId::new(Partition::User as u32, next);
168                next += 1;
169                e
170            });
171        }
172    }
173    let resolve = |entity: &EntityRef| -> Result<EntityId, TxError> {
174        match entity {
175            EntityRef::Id(e) => Ok(*e),
176            EntityRef::Temp(t) => Ok(tempids[t]),
177            EntityRef::Lookup(a, v) => {
178                let attr = db.schema().get(*a).ok_or(TxError::UnknownAttribute(*a))?;
179                if attr.unique.is_none() {
180                    return Err(TxError::LookupNotUnique);
181                }
182                db.lookup(*a, v).ok_or(TxError::LookupNotFound)
183            }
184        }
185    };
186    let mut datoms = Vec::new();
187    let mut working = db.clone();
188    let mut tx_instant_asserted = false;
189    for op in ops {
190        let start = datoms.len();
191        match op {
192            TxOp::Add(entity, a, v) => {
193                let e = resolve(&entity)?;
194                if a == bootstrap::TX_INSTANT {
195                    if e != tx || tx_instant_asserted {
196                        return Err(TxError::InvalidTxInstantOperation);
197                    }
198                    tx_instant_asserted = true;
199                }
200                validate(&working, a, &v)?;
201                if let Some(attr) = working.schema().get(a) {
202                    if attr.unique.is_some()
203                        && working.lookup(a, &v).is_some_and(|owner| owner != e)
204                    {
205                        return Err(TxError::UniqueConflict);
206                    }
207                    let current = working.values(e, a);
208                    // Re-asserting a present fact is a no-op: no datom recorded.
209                    if current.contains(&v) {
210                        continue;
211                    }
212                    if attr.cardinality == Cardinality::One {
213                        for old in current {
214                            datoms.push(Datom {
215                                e,
216                                a,
217                                v: old,
218                                tx,
219                                added: false,
220                            });
221                        }
222                    }
223                }
224                datoms.push(Datom {
225                    e,
226                    a,
227                    v,
228                    tx,
229                    added: true,
230                });
231            }
232            TxOp::Retract(entity, a, v) => {
233                if a == bootstrap::TX_INSTANT {
234                    return Err(TxError::InvalidTxInstantOperation);
235                }
236                validate(&working, a, &v)?;
237                let e = resolve(&entity)?;
238                // Retracting an absent fact is a no-op: no datom recorded.
239                if working.values(e, a).contains(&v) {
240                    datoms.push(Datom {
241                        e,
242                        a,
243                        v,
244                        tx,
245                        added: false,
246                    });
247                }
248            }
249            TxOp::Cas(entity, a, old, new) => {
250                if a == bootstrap::TX_INSTANT {
251                    return Err(TxError::InvalidTxInstantOperation);
252                }
253                validate(&working, a, &new)?;
254                let e = resolve(&entity)?;
255                if working
256                    .schema()
257                    .get(a)
258                    .is_none_or(|x| x.cardinality != Cardinality::One)
259                {
260                    return Err(TxError::CasCardinality);
261                }
262                let current = working.values(e, a).into_iter().next();
263                if current != old {
264                    return Err(TxError::CasFailed);
265                }
266                if let Some(value) = current {
267                    datoms.push(Datom {
268                        e,
269                        a,
270                        v: value,
271                        tx,
272                        added: false,
273                    });
274                }
275                datoms.push(Datom {
276                    e,
277                    a,
278                    v: new,
279                    tx,
280                    added: true,
281                });
282            }
283            TxOp::RetractEntity(entity) => {
284                let mut facts = BTreeSet::new();
285                collect_entity_retractions(
286                    &working,
287                    resolve(&entity)?,
288                    &mut facts,
289                    &mut BTreeSet::new(),
290                );
291                if facts.iter().any(|(_, a, _)| *a == bootstrap::TX_INSTANT) {
292                    return Err(TxError::InvalidTxInstantOperation);
293                }
294                datoms.extend(facts.into_iter().map(|(e, a, v)| Datom {
295                    e,
296                    a,
297                    v,
298                    tx,
299                    added: false,
300                }));
301            }
302        }
303        working = working.with_transaction(working.basis_t() + 1, &datoms[start..]);
304    }
305    Ok(PreparedTx { datoms, tempids })
306}
307
308fn validate(db: &Db, a: EntityId, value: &Value) -> Result<(), TxError> {
309    let attr = db.schema().get(a).ok_or(TxError::UnknownAttribute(a))?;
310    if !value.has_type(attr.value_type) {
311        return Err(TxError::TypeMismatch(a));
312    }
313    Ok(())
314}
315
316/// Collects the current facts removed by `:db/retractEntity` for `e`:
317/// the entity's own datoms, incoming references to it, and (recursively)
318/// its component children. Deduplicated by `(e, a, v)` because a component
319/// child's outgoing-ref datom is also an incoming reference to the child.
320fn collect_entity_retractions(
321    db: &Db,
322    e: EntityId,
323    facts: &mut BTreeSet<(EntityId, EntityId, Value)>,
324    seen: &mut BTreeSet<EntityId>,
325) {
326    if !seen.insert(e) {
327        return;
328    }
329    for datom in db.datoms() {
330        if datom.e == e {
331            if db.schema().get(datom.a).is_some_and(|a| a.is_component)
332                && let Value::Ref(child) = &datom.v
333            {
334                collect_entity_retractions(db, *child, facts, seen);
335            }
336            facts.insert((datom.e, datom.a, datom.v));
337        } else if datom.v == Value::Ref(e) {
338            facts.insert((datom.e, datom.a, datom.v));
339        }
340    }
341}