Skip to main content

corium_db/
lib.rs

1//! Immutable database values: time views, covering-index access, naming,
2//! per-attribute statistics, and bootstrap metadata.
3//!
4//! A [`Db`] is a value: cheap to clone, never mutated in place. Time views
5//! ([`Db::as_of`], [`Db::since`], [`Db::history`]) wrap the same recorded
6//! datoms with a different fold policy — no copying of facts. The four
7//! covering indexes for a view are materialized lazily on first read and
8//! shared by every clone of that value.
9//!
10//! Cheap to clone is not the same as cheap to hold or cheap to open: see
11//! [`Db`'s memory and cost notes](Db#memory), which say plainly what a
12//! holder of this value pays today and where that departs from the segment
13//! design in `docs/design/indexes-and-storage.md`.
14//!
15//! Transaction time is data: every committed transaction asserts
16//! `:db/txInstant` on its transaction entity (see [`bootstrap`]), so views can
17//! also be named by wall clock ([`Db::as_of_instant`], [`Db::since_instant`]).
18
19pub mod bootstrap;
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::sync::{Arc, OnceLock};
23
24use corium_core::{
25    AttrId, Cardinality, Datom, EntityId, IndexOrder, Keyword, KeywordInterner, Partition, Schema,
26    Unique, Value, encoding::Encodable,
27};
28use rpds::{RedBlackTreeMapSync, VectorSync};
29
30/// The first user-assignable sequence number. Lower ids are reserved for bootstrap data.
31pub const FIRST_USER_ID: u64 = 1_000;
32
33/// Time-view selector for a database value (see `docs/design/time-model.md`).
34#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
35pub enum DbView {
36    /// Live facts as of the basis transaction.
37    #[default]
38    Current,
39    /// Facts as they stood at basis `t` (inclusive).
40    AsOf(u64),
41    /// Only live facts added after `t` (exclusive).
42    Since(u64),
43    /// Every assertion and retraction ever recorded, except `:db/noHistory` attributes.
44    History,
45}
46
47/// Registry of `:db/ident` names for entities (attributes chiefly).
48#[derive(Clone, Debug, Default)]
49pub struct Idents {
50    by_keyword: BTreeMap<Keyword, EntityId>,
51    by_id: BTreeMap<EntityId, Keyword>,
52}
53
54impl Idents {
55    /// Registers an ident for an entity.
56    pub fn insert(&mut self, keyword: Keyword, id: EntityId) {
57        self.by_id.insert(id, keyword.clone());
58        self.by_keyword.insert(keyword, id);
59    }
60
61    /// Resolves a keyword to its entity id.
62    #[must_use]
63    pub fn entid(&self, keyword: &Keyword) -> Option<EntityId> {
64        self.by_keyword.get(keyword).copied()
65    }
66
67    /// Resolves an entity id back to its ident.
68    #[must_use]
69    pub fn ident(&self, id: EntityId) -> Option<&Keyword> {
70        self.by_id.get(&id)
71    }
72
73    /// Iterates all registered idents in keyword order.
74    pub fn iter(&self) -> impl Iterator<Item = (&Keyword, &EntityId)> {
75        self.by_keyword.iter()
76    }
77}
78
79/// Per-attribute statistics driving planner selectivity estimates.
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
81pub struct AttrStats {
82    /// Datoms carrying this attribute in the view.
83    pub count: usize,
84    /// Distinct values for this attribute.
85    pub distinct_values: usize,
86    /// Distinct entities carrying this attribute.
87    pub distinct_entities: usize,
88}
89
90/// Whole-view statistics for the query planner.
91#[derive(Clone, Debug, Default)]
92pub struct PlannerStats {
93    /// Statistics per attribute.
94    pub per_attr: BTreeMap<AttrId, AttrStats>,
95    /// Total datoms in the view.
96    pub total_datoms: usize,
97    /// Distinct entities in the view.
98    pub entity_count: usize,
99}
100
101impl PlannerStats {
102    /// Estimated datoms matched by a scan with the given bound components.
103    #[must_use]
104    pub fn estimate(&self, e_bound: bool, a: Option<AttrId>, v_bound: bool) -> usize {
105        let attr = a.and_then(|a| self.per_attr.get(&a));
106        match (e_bound, attr) {
107            // Bound entity: at most the entity's datoms; refine by attribute.
108            (true, Some(stats)) => (stats.count / stats.distinct_entities.max(1)).max(1),
109            (true, None) => (self.total_datoms / self.entity_count.max(1)).max(1),
110            (false, Some(stats)) if v_bound => (stats.count / stats.distinct_values.max(1)).max(1),
111            (false, Some(stats)) => stats.count.max(1),
112            // Unknown attribute constant: nothing will match.
113            (false, None) if a.is_some() => 1,
114            (false, None) => self.total_datoms.max(1),
115        }
116    }
117}
118
119/// Covering index for one order.
120///
121/// A persistent (structurally shared) ordered map: cloning is O(1) and shares
122/// every unchanged node with the parent, so applying a transaction to a
123/// materialized index derives a new map that copies only the O(log n) nodes on
124/// the touched paths instead of the whole tree. This is what keeps
125/// `with_transaction` — and the per-operation folding inside `corium-tx` —
126/// from re-cloning the entire index on every write.
127///
128/// Entries hold the datom [by handle](Db#memory): a datom is allocated once,
129/// when it is recorded, and the log and every covering index of every
130/// materialized view point at that one allocation. Only the encoded key is
131/// per-index, so a second order costs a key and a pointer rather than a
132/// duplicate of the fact — which matters most for the values that are
133/// themselves heap-allocated (strings, byte arrays), since those would
134/// otherwise be copied once per order per view.
135type Index = RedBlackTreeMapSync<Vec<u8>, Arc<Datom>>;
136
137const ORDERS: [IndexOrder; 4] = [
138    IndexOrder::Eavt,
139    IndexOrder::Aevt,
140    IndexOrder::Avet,
141    IndexOrder::Vaet,
142];
143
144const fn slot(order: IndexOrder) -> usize {
145    match order {
146        IndexOrder::Eavt => 0,
147        IndexOrder::Aevt => 1,
148        IndexOrder::Avet => 2,
149        IndexOrder::Vaet => 3,
150    }
151}
152
153/// Builds the encoded key prefix for a partial datom in one index order.
154///
155/// Components are consumed in the index's component order and encoding stops
156/// at the first missing component, so the result is a proper range prefix.
157#[must_use]
158pub fn key_prefix(
159    order: IndexOrder,
160    e: Option<EntityId>,
161    a: Option<AttrId>,
162    v: Option<&Value>,
163) -> Vec<u8> {
164    enum C {
165        E,
166        A,
167        V,
168    }
169    let components = match order {
170        IndexOrder::Eavt => [C::E, C::A, C::V],
171        IndexOrder::Aevt => [C::A, C::E, C::V],
172        IndexOrder::Avet => [C::A, C::V, C::E],
173        IndexOrder::Vaet => [C::V, C::A, C::E],
174    };
175    let mut out = Vec::new();
176    for component in components {
177        match component {
178            C::E => match e {
179                Some(e) => e.encode_into(&mut out),
180                None => break,
181            },
182            C::A => match a {
183                Some(a) => a.encode_into(&mut out),
184                None => break,
185            },
186            C::V => match v {
187                Some(v) => v.encode_into(&mut out),
188                None => break,
189            },
190        }
191    }
192    out
193}
194
195/// The `t` ↔ `:db/txInstant` correspondence for the transactions a value has
196/// seen, in both directions.
197///
198/// Both maps are persistent, so carrying them on every database value costs
199/// one O(log n) insert per transaction and nothing per clone. They are a
200/// property of the recorded transactions rather than of a time view, so a
201/// derived view (`as-of`, `since`, `history`) keeps the whole correspondence:
202/// naming a view by instant means the same thing whatever view you start from.
203///
204/// `:db/txInstant` is monotone by construction (the transactor stamps
205/// `max(now, last + 1)`), so ordering by instant and ordering by `t` agree.
206#[derive(Clone, Debug, Default)]
207pub struct TxInstants {
208    by_t: RedBlackTreeMapSync<u64, i64>,
209    by_instant: RedBlackTreeMapSync<i64, u64>,
210}
211
212impl TxInstants {
213    fn record(&mut self, t: u64, instant: i64) {
214        self.by_t.insert_mut(t, instant);
215        // Equal instants can only come from a log written without the
216        // monotonicity rule; keeping the highest `t` makes resolution pick the
217        // last transaction that shares an instant, matching `as-of`'s
218        // inclusive semantics.
219        if self.by_instant.get(&instant).is_none_or(|held| *held < t) {
220            self.by_instant.insert_mut(instant, t);
221        }
222    }
223
224    /// The commit instant of transaction `t`.
225    #[must_use]
226    pub fn instant(&self, t: u64) -> Option<i64> {
227        self.by_t.get(&t).copied()
228    }
229
230    /// The latest `t` committed at or before `instant`, or zero when no known
231    /// transaction is that old (the basis of an empty database).
232    #[must_use]
233    pub fn t_at(&self, instant: i64) -> u64 {
234        self.by_instant
235            .range(..=instant)
236            .next_back()
237            .map_or(0, |(_, t)| *t)
238    }
239
240    /// Number of transactions with a known instant.
241    #[must_use]
242    pub fn len(&self) -> usize {
243        self.by_t.size()
244    }
245
246    /// Whether no transaction instant is known.
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.by_t.is_empty()
250    }
251}
252
253/// An immutable value of a database at one basis transaction and time view.
254///
255/// # Memory
256///
257/// This value is fully resident. `recorded` holds **every datom the value has
258/// seen** — assertions and retractions alike, the whole history, not just the
259/// live facts — and a materialized view adds four covering indexes over it.
260/// A holder therefore pays, per datom:
261///
262/// - one heap allocation for the datom itself, shared by handle with every
263///   index entry that refers to it, in every materialized view; plus
264/// - one encoded key per covering order the datom is in (always EAVT and
265///   AEVT, plus AVET for an indexed or unique attribute and VAET for a
266///   reference value), plus a pointer per entry.
267///
268/// So a fact is stored once however many orders and views index it. The keys
269/// are not free — a key embeds the encoded value, so a long string still
270/// appears once per covering order — but they are the ordering itself rather
271/// than a duplicate of the datom.
272///
273/// What is *not* bounded is history: nothing here is ever evicted, and a
274/// peer's resident set grows with everything ever transacted rather than
275/// with the live database. A database whose history greatly exceeds its live
276/// set will be dominated by datoms no current query can reach.
277///
278/// # Cost of a time view
279///
280/// Materializing a view is O(recorded history), not O(the view's size).
281/// [`Db::as_of`] folds the log up to its basis, [`Db::history`] folds all of
282/// it, and [`Db::since`] folds all of it and then filters. The result is
283/// cached in this value and shared by its clones — and views that select
284/// exactly the same datoms as an already-folded one share that fold rather
285/// than repeating it — but a *distinct* time view is a fresh fold over the
286/// whole history the first time it is read. Deriving many different `as-of`
287/// values, or re-deriving one from a fresh [`Db`] each time, pays that fold
288/// each time.
289///
290/// # How this differs from the design
291///
292/// `docs/design/indexes-and-storage.md` describes readers descending
293/// persistent segment trees and pulling only the segments a query touches,
294/// through a bounded cache. That is what the published format is being built
295/// toward; the inner tree levels that would let a reader seek without
296/// downloading an index are still future work. Until they land, a peer holds
297/// the whole database — and its whole history — in memory, and durable
298/// storage serves to reconstruct that state rather than to bound it.
299#[derive(Clone, Debug, Default)]
300pub struct Db {
301    basis_t: u64,
302    schema: Schema,
303    recorded: VectorSync<Arc<Datom>>,
304    idents: Arc<Idents>,
305    interner: Arc<KeywordInterner>,
306    view: DbView,
307    instants: TxInstants,
308    indexes: Arc<OnceLock<[Index; 4]>>,
309    stats: Arc<OnceLock<PlannerStats>>,
310}
311
312impl Db {
313    /// Creates an empty database with the supplied schema.
314    ///
315    /// The engine's own attributes ([`bootstrap`]) are installed over
316    /// `schema`, so every database understands `:db/txInstant` whether or not
317    /// the caller's schema mentions it.
318    #[must_use]
319    pub fn new(mut schema: Schema) -> Self {
320        bootstrap::install_schema(&mut schema);
321        Self {
322            schema,
323            ..Self::default()
324        }
325    }
326
327    /// Creates a current database value from a published EAVT snapshot.
328    ///
329    /// `datoms` must be the live facts at `basis_t`. Their original
330    /// transaction ids are retained, but transactions before `basis_t` that
331    /// no longer contribute a live fact are not reconstructed. This makes
332    /// the value suitable for current queries and for applying the log tail;
333    /// complete historical views still require replaying the full log.
334    ///
335    /// `:db/txInstant` datoms are live facts (nothing retracts them), so a
336    /// snapshot carries the transaction-time correspondence for every
337    /// transaction it covers — unless it was published before the engine
338    /// recorded instants as datoms, in which case instant-named views resolve
339    /// only within the replayed tail.
340    #[must_use]
341    pub fn from_current_snapshot(
342        basis_t: u64,
343        mut schema: Schema,
344        mut idents: Idents,
345        interner: KeywordInterner,
346        datoms: Vec<Datom>,
347    ) -> Self {
348        bootstrap::install(&mut schema, &mut idents);
349        let mut instants = TxInstants::default();
350        for datom in &datoms {
351            if let Datom {
352                e,
353                a,
354                v: Value::Instant(instant),
355                tx,
356                added: true,
357                ..
358            } = datom
359                && *a == bootstrap::TX_INSTANT
360                && *e == *tx
361                && tx.partition() == Partition::Tx as u32
362            {
363                instants.record(tx.sequence(), *instant);
364            }
365        }
366        Self {
367            basis_t,
368            schema,
369            recorded: datoms.into_iter().map(Arc::new).collect(),
370            idents: Arc::new(idents),
371            interner: Arc::new(interner),
372            view: DbView::Current,
373            instants,
374            indexes: Arc::new(OnceLock::new()),
375            stats: Arc::new(OnceLock::new()),
376        }
377    }
378
379    /// Attaches ident and keyword naming registries, returning the named value.
380    #[must_use]
381    pub fn with_naming(mut self, mut idents: Idents, interner: KeywordInterner) -> Self {
382        bootstrap::install(&mut self.schema, &mut idents);
383        self.idents = Arc::new(idents);
384        self.interner = Arc::new(interner);
385        self
386    }
387
388    /// Current transaction basis.
389    #[must_use]
390    pub const fn basis_t(&self) -> u64 {
391        self.basis_t
392    }
393
394    /// Schema at this basis.
395    #[must_use]
396    pub const fn schema(&self) -> &Schema {
397        &self.schema
398    }
399
400    /// Ident registry.
401    #[must_use]
402    pub fn idents(&self) -> &Idents {
403        &self.idents
404    }
405
406    /// Keyword interner used by keyword values in this database.
407    #[must_use]
408    pub fn interner(&self) -> &KeywordInterner {
409        &self.interner
410    }
411
412    /// The time view this value presents.
413    #[must_use]
414    pub const fn view(&self) -> DbView {
415        self.view
416    }
417
418    /// Every recorded assertion and retraction, in transaction order.
419    pub fn recorded_datoms(&self) -> impl Iterator<Item = &Datom> {
420        self.recorded.iter().map(AsRef::as_ref)
421    }
422
423    /// Number of recorded assertions and retractions.
424    #[must_use]
425    pub fn recorded_len(&self) -> usize {
426        self.recorded.len()
427    }
428
429    /// The datoms transactions after `t` recorded, in the order they were
430    /// recorded.
431    ///
432    /// `recorded` is append-ordered, so the answer is a suffix: the scan
433    /// walks back from the end and stops at the first datom at or before `t`,
434    /// costing time proportional to the tail rather than to the whole
435    /// history. This is what an indexing pass folds into the segments it last
436    /// published ([`corium_index::Segment::apply`]) instead of rebuilding
437    /// them.
438    ///
439    /// A value opened from a published snapshot carries that snapshot's live
440    /// datoms as its prefix rather than a transaction-ordered history; every
441    /// one of them is at or before the snapshot's basis, so the same scan
442    /// still stops at the boundary for any `t` at or after it.
443    pub fn recorded_since(&self, t: u64) -> impl Iterator<Item = &Datom> {
444        let mut start = self.recorded.len();
445        while start > 0
446            && self
447                .recorded
448                .get(start - 1)
449                .is_some_and(|datom| datom.tx.sequence() > t)
450        {
451            start -= 1;
452        }
453        // Indexed access rather than `iter().skip(start)`: the persistent
454        // vector's iterator would have to walk the whole prefix to reach the
455        // tail, which is the cost this method exists to avoid.
456        (start..self.recorded.len())
457            .filter_map(|index| self.recorded.get(index))
458            .map(AsRef::as_ref)
459    }
460
461    /// Returns the as-of view at basis `t`: facts as they stood then.
462    #[must_use]
463    pub fn as_of(&self, t: u64) -> Self {
464        self.with_view(DbView::AsOf(t))
465    }
466
467    /// Returns the since view: only live facts added after `t`.
468    #[must_use]
469    pub fn since(&self, t: u64) -> Self {
470        self.with_view(DbView::Since(t))
471    }
472
473    /// Returns the history view: all assertions and retractions ever.
474    #[must_use]
475    pub fn history(&self) -> Self {
476        self.with_view(DbView::History)
477    }
478
479    /// The `t` ↔ `:db/txInstant` correspondence recorded by this value.
480    #[must_use]
481    pub const fn instants(&self) -> &TxInstants {
482        &self.instants
483    }
484
485    /// The commit instant of transaction `t`, when this value has seen it.
486    #[must_use]
487    pub fn tx_instant(&self, t: u64) -> Option<i64> {
488        self.instants.instant(t)
489    }
490
491    /// The latest basis committed at or before `instant` (Unix milliseconds).
492    ///
493    /// Zero when no known transaction is that old, so `as-of` an instant
494    /// before the database existed is the empty value and `since` that instant
495    /// is everything — the same interpretation Datomic gives out-of-range
496    /// instants.
497    #[must_use]
498    pub fn t_at_instant(&self, instant: i64) -> u64 {
499        self.instants.t_at(instant)
500    }
501
502    /// Returns the as-of view at wall-clock `instant` (Unix milliseconds):
503    /// facts as they stood after the last transaction committed at or before
504    /// it.
505    #[must_use]
506    pub fn as_of_instant(&self, instant: i64) -> Self {
507        self.as_of(self.t_at_instant(instant))
508    }
509
510    /// Returns the since view at wall-clock `instant` (Unix milliseconds):
511    /// only live facts added after the last transaction committed at or before
512    /// it.
513    #[must_use]
514    pub fn since_instant(&self, instant: i64) -> Self {
515        self.since(self.t_at_instant(instant))
516    }
517
518    fn with_view(&self, view: DbView) -> Self {
519        if view == self.view {
520            return self.clone();
521        }
522        // Two views that select the same datoms share one fold, so a view
523        // that merely names the current value does not pay to rebuild it.
524        // The declared view is kept as asked for: callers distinguish a time
525        // view from the current value regardless of what it holds (writes,
526        // for instance, are refused on any time view).
527        let shared = self.fold_class(view) == self.fold_class(self.view);
528        Self {
529            view,
530            indexes: if shared {
531                Arc::clone(&self.indexes)
532            } else {
533                Arc::new(OnceLock::new())
534            },
535            stats: if shared {
536                Arc::clone(&self.stats)
537            } else {
538                Arc::new(OnceLock::new())
539            },
540            ..self.clone()
541        }
542    }
543
544    /// Canonicalizes a view to the fold that materializes it, so views that
545    /// differ only in name share an index and statistics cache.
546    ///
547    /// Every recorded datom belongs to a transaction at or before the basis,
548    /// so an `as-of` at or after the basis excludes nothing; and transaction
549    /// numbering starts at one, so a `since` at basis zero excludes nothing
550    /// either. Both therefore fold exactly as the current view does.
551    const fn fold_class(&self, view: DbView) -> DbView {
552        match view {
553            DbView::AsOf(t) if t >= self.basis_t => DbView::Current,
554            DbView::Since(0) => DbView::Current,
555            other => other,
556        }
557    }
558
559    /// Groups recorded datoms by transaction over the half-open range `[start, end)`.
560    #[must_use]
561    pub fn tx_range(&self, start: u64, end: Option<u64>) -> Vec<(u64, Vec<Datom>)> {
562        let mut by_t: BTreeMap<u64, Vec<Datom>> = BTreeMap::new();
563        for datom in &self.recorded {
564            let t = datom.tx.sequence();
565            if t >= start && end.is_none_or(|end| t < end) {
566                by_t.entry(t).or_default().push((**datom).clone());
567            }
568        }
569        by_t.into_iter().collect()
570    }
571
572    /// Returns this view's facts, deterministically ordered by EAVT.
573    #[must_use]
574    pub fn datoms(&self) -> Vec<Datom> {
575        self.datoms_at(IndexOrder::Eavt).cloned().collect()
576    }
577
578    /// Iterates this view's datoms in one index order.
579    ///
580    /// AVET covers only indexed/unique attributes and VAET only reference
581    /// values, mirroring Datomic's covering-index composition.
582    pub fn datoms_at(&self, order: IndexOrder) -> impl Iterator<Item = &Datom> {
583        self.indexes()[slot(order)].values().map(AsRef::as_ref)
584    }
585
586    /// Iterates this view's datoms for one attribute in AEVT order.
587    ///
588    /// Unlike AVET, AEVT covers every installed attribute. Callers can use
589    /// this as the fallback for attribute predicates that do not have AVET
590    /// coverage.
591    pub fn datoms_for_attribute(&self, a: AttrId) -> impl Iterator<Item = &Datom> {
592        let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
593        self.indexes()[slot(IndexOrder::Aevt)]
594            .range(prefix.clone()..)
595            .take_while(move |(key, _)| key.starts_with(&prefix))
596            .map(|(_, datom)| datom.as_ref())
597    }
598
599    /// Iterates datoms whose key in `order` starts with `prefix`.
600    pub fn datoms_prefix<'a>(
601        &'a self,
602        order: IndexOrder,
603        prefix: &'a [u8],
604    ) -> impl Iterator<Item = &'a Datom> {
605        self.indexes()[slot(order)]
606            .range(prefix.to_vec()..)
607            .take_while(move |(key, _)| key.starts_with(prefix))
608            .map(|(_, datom)| datom.as_ref())
609    }
610
611    /// Iterates datoms in `order` starting from the first key at or after `start`.
612    pub fn seek_datoms<'a>(
613        &'a self,
614        order: IndexOrder,
615        start: &[u8],
616    ) -> impl Iterator<Item = &'a Datom> {
617        self.indexes()[slot(order)]
618            .range(start.to_vec()..)
619            .map(|(_, datom)| datom.as_ref())
620    }
621
622    /// Iterates the AVET index for `a` over the value range `[start, end)`.
623    ///
624    /// Only indexed/unique attributes appear in AVET.
625    pub fn index_range<'a>(
626        &'a self,
627        a: AttrId,
628        start: Option<&Value>,
629        end: Option<&'a Value>,
630    ) -> impl Iterator<Item = &'a Datom> {
631        let a_prefix = key_prefix(IndexOrder::Avet, None, Some(a), None);
632        let start_key = key_prefix(IndexOrder::Avet, None, Some(a), start);
633        self.indexes()[slot(IndexOrder::Avet)]
634            .range(start_key..)
635            .take_while(move |(key, _)| key.starts_with(&a_prefix))
636            .map(|(_, datom)| datom.as_ref())
637            .take_while(move |datom| end.is_none_or(|end| datom.v < *end))
638    }
639
640    /// Current values for an entity/attribute pair.
641    #[must_use]
642    pub fn values(&self, e: EntityId, a: AttrId) -> Vec<Value> {
643        let prefix = key_prefix(IndexOrder::Eavt, Some(e), Some(a), None);
644        self.datoms_prefix(IndexOrder::Eavt, &prefix)
645            .map(|datom| datom.v.clone())
646            .collect()
647    }
648
649    /// Resolves a unique attribute/value pair to its entity.
650    #[must_use]
651    pub fn lookup(&self, a: AttrId, v: &Value) -> Option<EntityId> {
652        if avet_covered(&self.schema, a) {
653            let prefix = key_prefix(IndexOrder::Avet, None, Some(a), Some(v));
654            self.datoms_prefix(IndexOrder::Avet, &prefix)
655                .next()
656                .map(|datom| datom.e)
657        } else {
658            let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
659            self.datoms_prefix(IndexOrder::Aevt, &prefix)
660                .find(|datom| datom.v == *v)
661                .map(|datom| datom.e)
662        }
663    }
664
665    /// Applies a committed record, returning a new database value.
666    ///
667    /// Only meaningful for the current view; time views are read-only.
668    ///
669    /// A `:db/txInstant` assertion on the transaction entity — which every
670    /// commit path materializes — is picked up as this transaction's place on
671    /// the wall clock.
672    #[must_use]
673    pub fn with_transaction(&self, t: u64, datoms: &[Datom]) -> Self {
674        match bootstrap::asserted_instant(t, datoms) {
675            Some(instant) => self.with_transaction_at(t, instant, datoms),
676            None => self.apply_transaction(t, datoms),
677        }
678    }
679
680    /// Applies a committed record whose commit instant is known separately,
681    /// materializing the `:db/txInstant` datom when `datoms` lacks one.
682    ///
683    /// Replay paths use this so a log written before Corium recorded instants
684    /// as datoms still yields instant-named views: the record's timestamp
685    /// field becomes the datom it would carry today.
686    #[must_use]
687    pub fn with_transaction_at(&self, t: u64, instant: i64, datoms: &[Datom]) -> Self {
688        let asserted = bootstrap::asserted_instant(t, datoms);
689        let mut next = if asserted.is_some() {
690            self.apply_transaction(t, datoms)
691        } else {
692            let mut with_instant = Vec::with_capacity(datoms.len() + 1);
693            with_instant.extend_from_slice(datoms);
694            with_instant.push(bootstrap::tx_instant_datom(t, instant));
695            self.apply_transaction(t, &with_instant)
696        };
697        next.instants.record(t, asserted.unwrap_or(instant));
698        next
699    }
700
701    fn apply_transaction(&self, t: u64, datoms: &[Datom]) -> Self {
702        debug_assert!(
703            self.view == DbView::Current,
704            "with_transaction applies only to the current view"
705        );
706        let mut next = self.clone();
707        next.basis_t = t;
708        // Allocate each datom once here: the log entry and every covering
709        // index entry derived from it below are handles on this one
710        // allocation, not copies of the fact.
711        let arrived: Vec<Arc<Datom>> = datoms.iter().cloned().map(Arc::new).collect();
712        // `recorded` is a persistent vector: this clone shared its whole spine
713        // with the parent, and appending copies only the O(log n) nodes on the
714        // tail path — never the entire log, even while `db_before` keeps the
715        // parent alive.
716        for datom in &arrived {
717            next.recorded.push_back_mut(Arc::clone(datom));
718        }
719        next.indexes = Arc::new(OnceLock::new());
720        next.stats = Arc::new(OnceLock::new());
721        // Derive indexes incrementally when the parent already built them, so
722        // transaction pipelines don't refold the whole history per operation.
723        if let Some(parent) = self.indexes.get() {
724            let mut derived = parent.clone();
725            apply_current(&mut derived, arrived.iter(), &self.schema);
726            let _ = next.indexes.set(derived);
727        }
728        next
729    }
730
731    /// Computes basic statistics over this view's facts.
732    #[must_use]
733    pub fn stats(&self) -> DbStats {
734        let planner = self.planner_stats();
735        DbStats {
736            datoms: planner.total_datoms,
737            entities: planner.entity_count,
738            attributes: planner.per_attr.len(),
739        }
740    }
741
742    /// Planner statistics for this view, built lazily and cached.
743    #[must_use]
744    pub fn planner_stats(&self) -> &PlannerStats {
745        self.stats.get_or_init(|| {
746            let mut stats = PlannerStats::default();
747            let mut values: BTreeMap<AttrId, BTreeSet<&Value>> = BTreeMap::new();
748            let mut attr_entities: BTreeMap<AttrId, BTreeSet<EntityId>> = BTreeMap::new();
749            let mut entities: BTreeSet<EntityId> = BTreeSet::new();
750            for datom in self.datoms_at(IndexOrder::Eavt) {
751                stats.total_datoms += 1;
752                stats.per_attr.entry(datom.a).or_default().count += 1;
753                values.entry(datom.a).or_default().insert(&datom.v);
754                attr_entities.entry(datom.a).or_default().insert(datom.e);
755                entities.insert(datom.e);
756            }
757            for (a, entry) in &mut stats.per_attr {
758                entry.distinct_values = values.get(a).map_or(0, BTreeSet::len);
759                entry.distinct_entities = attr_entities.get(a).map_or(0, BTreeSet::len);
760            }
761            stats.entity_count = entities.len();
762            stats
763        })
764    }
765
766    fn indexes(&self) -> &[Index; 4] {
767        self.indexes.get_or_init(|| {
768            let mut indexes: [Index; 4] = Default::default();
769            match self.view {
770                DbView::History => {
771                    for datom in &self.recorded {
772                        if self.schema.get(datom.a).is_some_and(|a| a.no_history) {
773                            continue;
774                        }
775                        insert_datom(&mut indexes, datom, &self.schema, true);
776                    }
777                }
778                DbView::Current | DbView::AsOf(_) | DbView::Since(_) => {
779                    // One fold decides liveness for all four orders. A
780                    // retraction cancels the assertion sharing its
781                    // `(e, a, v)`, and that is the EAVT key, so the live set
782                    // can be folded in EAVT alone and the other three orders
783                    // projected from it. Folding once means a fact that is
784                    // later retracted churns one tree rather than four, and
785                    // nothing that never survives the fold is ever encoded
786                    // into AEVT/AVET/VAET.
787                    let cutoff = match self.view {
788                        DbView::AsOf(t) => Some(t),
789                        _ => None,
790                    };
791                    let mut live = Index::new_sync();
792                    for datom in &self.recorded {
793                        if cutoff.is_some_and(|t| datom.tx.sequence() > t) {
794                            continue;
795                        }
796                        let key = key_prefix(
797                            IndexOrder::Eavt,
798                            Some(datom.e),
799                            Some(datom.a),
800                            Some(&datom.v),
801                        );
802                        if datom.added {
803                            live.insert_mut(key, Arc::clone(datom));
804                        } else {
805                            live.remove_mut(&key);
806                        }
807                    }
808                    // `since` keeps only the live facts a later transaction
809                    // added. Narrowing the live set before projecting costs
810                    // one transient tree of keys and handles; filtering the
811                    // four built indexes instead — the shape this replaces —
812                    // held four whole indexes and four filtered copies of
813                    // them alive at once.
814                    if let DbView::Since(t) = self.view {
815                        live = live
816                            .iter()
817                            .filter(|(_, datom)| datom.tx.sequence() > t)
818                            .map(|(key, datom)| (key.clone(), Arc::clone(datom)))
819                            .collect();
820                    }
821                    for datom in live.values() {
822                        for order in [IndexOrder::Aevt, IndexOrder::Avet, IndexOrder::Vaet] {
823                            if covered(&self.schema, order, datom) {
824                                let key =
825                                    key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v));
826                                indexes[slot(order)].insert_mut(key, Arc::clone(datom));
827                            }
828                        }
829                    }
830                    indexes[slot(IndexOrder::Eavt)] = live;
831                }
832            }
833            indexes
834        })
835    }
836}
837
838/// Folds assertions/retractions into current-view indexes.
839///
840/// Current views key entries by components only (no transaction suffix):
841/// at most one live datom exists per `(e, a, v)`, and retractions must
842/// erase the assertion regardless of which transactions produced them.
843fn apply_current<'a>(
844    indexes: &mut [Index; 4],
845    datoms: impl Iterator<Item = &'a Arc<Datom>>,
846    schema: &Schema,
847) {
848    for datom in datoms {
849        if datom.added {
850            insert_datom(indexes, datom, schema, false);
851        } else {
852            for order in ORDERS {
853                if covered(schema, order, datom) {
854                    let key = key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v));
855                    indexes[slot(order)].remove_mut(&key);
856                }
857            }
858        }
859    }
860}
861
862fn insert_datom(indexes: &mut [Index; 4], datom: &Arc<Datom>, schema: &Schema, with_tx: bool) {
863    for order in ORDERS {
864        if covered(schema, order, datom) {
865            let key = if with_tx {
866                datom.key(order)
867            } else {
868                key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v))
869            };
870            indexes[slot(order)].insert_mut(key, Arc::clone(datom));
871        }
872    }
873}
874
875/// Whether `datom` belongs in `order`'s covering index.
876///
877/// EAVT and AEVT hold every datom; AVET holds only indexed/unique
878/// attributes and VAET only reference values, mirroring Datomic's covering
879/// index composition. An indexing pass folding a transaction tail into
880/// published segments filters it through the same rule the in-memory
881/// indexes use, so the two cannot drift.
882#[must_use]
883pub fn covered(schema: &Schema, order: IndexOrder, datom: &Datom) -> bool {
884    match order {
885        IndexOrder::Eavt | IndexOrder::Aevt => true,
886        IndexOrder::Avet => avet_covered(schema, datom.a),
887        IndexOrder::Vaet => matches!(datom.v, Value::Ref(_)),
888    }
889}
890
891/// Whether the attribute participates in the AVET covering index.
892#[must_use]
893pub fn avet_covered(schema: &Schema, a: AttrId) -> bool {
894    schema
895        .get(a)
896        .is_some_and(|attr| attr.indexed || attr.unique.is_some())
897}
898
899/// Counts over one database view.
900#[derive(Clone, Copy, Debug, Eq, PartialEq)]
901pub struct DbStats {
902    /// Facts in the view.
903    pub datoms: usize,
904    /// Entities having at least one fact in the view.
905    pub entities: usize,
906    /// Attributes used by facts in the view.
907    pub attributes: usize,
908}
909
910/// Convenience constructor for schema attributes used during bootstrap/tests.
911#[must_use]
912pub const fn attribute(
913    id: u64,
914    value_type: corium_core::ValueType,
915    cardinality: Cardinality,
916    unique: Option<Unique>,
917) -> corium_core::Attribute {
918    corium_core::Attribute {
919        id: EntityId::new(Partition::Db as u32, id),
920        value_type,
921        cardinality,
922        unique,
923        is_component: false,
924        indexed: unique.is_some(),
925        no_history: false,
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use corium_core::ValueType;
933
934    fn schema() -> Schema {
935        let mut schema = Schema::default();
936        schema.insert(attribute(1, ValueType::Str, Cardinality::One, None));
937        schema.insert(attribute(
938            2,
939            ValueType::Long,
940            Cardinality::One,
941            Some(Unique::Identity),
942        ));
943        schema.insert(attribute(3, ValueType::Ref, Cardinality::Many, None));
944        schema
945    }
946
947    fn attr(id: u64) -> AttrId {
948        EntityId::new(Partition::Db as u32, id)
949    }
950
951    fn entity(id: u64) -> EntityId {
952        EntityId::new(Partition::User as u32, id)
953    }
954
955    fn tx_entity(t: u64) -> EntityId {
956        EntityId::new(Partition::Tx as u32, t)
957    }
958
959    fn datom(e: u64, a: u64, v: Value, t: u64, added: bool) -> Datom {
960        Datom {
961            e: entity(e),
962            a: attr(a),
963            v,
964            tx: tx_entity(t),
965            added,
966        }
967    }
968
969    fn sample() -> Db {
970        Db::new(schema())
971            .with_transaction(
972                1,
973                &[
974                    datom(1, 1, Value::Str("alice".into()), 1, true),
975                    datom(1, 2, Value::Long(7), 1, true),
976                ],
977            )
978            .with_transaction(
979                2,
980                &[
981                    datom(1, 1, Value::Str("alice".into()), 2, false),
982                    datom(1, 1, Value::Str("alicia".into()), 2, true),
983                    datom(2, 3, Value::Ref(entity(1)), 2, true),
984                ],
985            )
986    }
987
988    #[test]
989    fn current_view_folds_retractions() {
990        let db = sample();
991        assert_eq!(
992            db.values(entity(1), attr(1)),
993            vec![Value::Str("alicia".into())]
994        );
995        assert_eq!(db.stats().datoms, 3);
996    }
997
998    #[test]
999    fn recorded_since_returns_the_transaction_tail() {
1000        let db = sample();
1001        assert_eq!(db.recorded_since(2).count(), 0);
1002        let tail: Vec<_> = db.recorded_since(1).collect();
1003        assert_eq!(tail.len(), 3);
1004        assert!(tail.iter().all(|datom| datom.tx.sequence() == 2));
1005        assert_eq!(db.recorded_since(0).count(), db.recorded_len());
1006
1007        // A value opened from a published snapshot carries live datoms rather
1008        // than a transaction-ordered history; the tail after it still scans.
1009        let snapshot = Db::from_current_snapshot(
1010            2,
1011            schema(),
1012            Idents::default(),
1013            KeywordInterner::default(),
1014            db.datoms(),
1015        )
1016        .with_transaction(3, &[datom(3, 1, Value::Str("carol".into()), 3, true)]);
1017        let tail: Vec<_> = snapshot.recorded_since(2).collect();
1018        assert_eq!(tail.len(), 1);
1019        assert!(tail.iter().all(|datom| datom.tx.sequence() == 3));
1020    }
1021
1022    #[test]
1023    fn attribute_scan_uses_complete_aevt_coverage() {
1024        let db = sample();
1025        let datoms = db.datoms_for_attribute(attr(1)).collect::<Vec<_>>();
1026        assert_eq!(datoms.len(), 1);
1027        assert_eq!(datoms[0].v, Value::Str("alicia".into()));
1028        assert!(datoms.iter().all(|datom| datom.a == attr(1)));
1029    }
1030
1031    #[test]
1032    fn as_of_reconstructs_past_basis() {
1033        let db = sample().as_of(1);
1034        assert_eq!(
1035            db.values(entity(1), attr(1)),
1036            vec![Value::Str("alice".into())]
1037        );
1038        assert_eq!(db.stats().datoms, 2);
1039    }
1040
1041    #[test]
1042    fn since_excludes_older_live_facts() {
1043        let db = sample().since(1);
1044        // The long asserted at t=1 is invisible; the renamed string is visible.
1045        assert_eq!(db.values(entity(1), attr(2)), Vec::<Value>::new());
1046        assert_eq!(
1047            db.values(entity(1), attr(1)),
1048            vec![Value::Str("alicia".into())]
1049        );
1050    }
1051
1052    #[test]
1053    fn history_exposes_assertions_and_retractions() {
1054        let db = sample().history();
1055        let names: Vec<_> = db
1056            .datoms_prefix(
1057                IndexOrder::Eavt,
1058                &key_prefix(IndexOrder::Eavt, Some(entity(1)), Some(attr(1)), None),
1059            )
1060            .map(|d| (d.v.clone(), d.added))
1061            .collect();
1062        assert_eq!(names.len(), 3);
1063        assert!(names.contains(&(Value::Str("alice".into()), false)));
1064    }
1065
1066    #[test]
1067    fn since_filters_the_live_set_in_every_order() {
1068        // The four orders are projections of one filtered live set, so the
1069        // floor has to hold in all of them and not just the one folded first.
1070        let db = sample().since(1);
1071        assert_eq!(db.datoms_at(IndexOrder::Eavt).count(), 2);
1072        assert_eq!(db.datoms_at(IndexOrder::Aevt).count(), 2);
1073        // The sole AVET-covered attribute's live datom was asserted at t=1.
1074        assert_eq!(db.datoms_at(IndexOrder::Avet).count(), 0);
1075        assert_eq!(db.datoms_at(IndexOrder::Vaet).count(), 1);
1076        for order in ORDERS {
1077            assert!(db.datoms_at(order).all(|datom| datom.tx.sequence() > 1));
1078        }
1079    }
1080
1081    #[test]
1082    fn a_datom_is_allocated_once_and_shared_by_the_log_and_every_index() {
1083        let db = sample();
1084        let indexes = db.indexes();
1085        for (_, eavt) in &indexes[slot(IndexOrder::Eavt)] {
1086            // AEVT covers every datom, and holds this one by handle rather
1087            // than as a second copy of the fact.
1088            let aevt = indexes[slot(IndexOrder::Aevt)]
1089                .get(&key_prefix(
1090                    IndexOrder::Aevt,
1091                    Some(eavt.e),
1092                    Some(eavt.a),
1093                    Some(&eavt.v),
1094                ))
1095                .expect("AEVT covers every datom");
1096            assert!(Arc::ptr_eq(eavt, aevt), "AEVT copied the datom");
1097            assert!(
1098                db.recorded.iter().any(|entry| Arc::ptr_eq(entry, eavt)),
1099                "the index copied the datom instead of sharing the log's"
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn views_that_name_the_current_value_share_its_fold() {
1106        let db = sample();
1107        let _ = db.datoms();
1108        for view in [
1109            db.as_of(db.basis_t()),
1110            db.as_of(db.basis_t() + 10),
1111            db.since(0),
1112        ] {
1113            assert!(
1114                Arc::ptr_eq(&db.indexes, &view.indexes),
1115                "{:?} refolded the whole history to reach the current value",
1116                view.view()
1117            );
1118            assert!(Arc::ptr_eq(&db.stats, &view.stats));
1119            assert_eq!(view.datoms(), db.datoms());
1120        }
1121        // A view that genuinely selects a different set folds its own.
1122        for view in [db.as_of(1), db.since(1), db.history()] {
1123            assert!(!Arc::ptr_eq(&db.indexes, &view.indexes));
1124        }
1125    }
1126
1127    #[test]
1128    fn a_view_reports_the_time_view_it_was_asked_for() {
1129        // Sharing a fold must not restate an as-of view as the current value:
1130        // callers key behaviour off the declared view (writes are refused on
1131        // any time view) whatever facts it happens to hold.
1132        let db = sample();
1133        assert_eq!(db.view(), DbView::Current);
1134        assert_eq!(db.as_of(db.basis_t()).view(), DbView::AsOf(db.basis_t()));
1135        assert_eq!(db.since(0).view(), DbView::Since(0));
1136    }
1137
1138    #[test]
1139    fn avet_only_covers_indexed_attributes() {
1140        let db = sample();
1141        assert_eq!(db.datoms_at(IndexOrder::Avet).count(), 1);
1142        assert_eq!(db.datoms_at(IndexOrder::Vaet).count(), 1);
1143    }
1144
1145    #[test]
1146    fn index_range_scans_value_bounds() {
1147        let db = Db::new(schema()).with_transaction(
1148            1,
1149            &[
1150                datom(1, 2, Value::Long(1), 1, true),
1151                datom(2, 2, Value::Long(5), 1, true),
1152                datom(3, 2, Value::Long(9), 1, true),
1153            ],
1154        );
1155        let hits: Vec<_> = db
1156            .index_range(attr(2), Some(&Value::Long(2)), Some(&Value::Long(9)))
1157            .map(|d| d.e)
1158            .collect();
1159        assert_eq!(hits, vec![entity(2)]);
1160    }
1161
1162    /// The same two transactions as [`sample`], committed at known instants
1163    /// the way the transactor commits them.
1164    fn timed_sample() -> Db {
1165        Db::new(schema())
1166            .with_transaction_at(
1167                1,
1168                1_000,
1169                &[datom(1, 1, Value::Str("alice".into()), 1, true)],
1170            )
1171            .with_transaction_at(
1172                2,
1173                2_000,
1174                &[
1175                    datom(1, 1, Value::Str("alice".into()), 2, false),
1176                    datom(1, 1, Value::Str("alicia".into()), 2, true),
1177                ],
1178            )
1179    }
1180
1181    #[test]
1182    fn commit_instants_are_recorded_in_both_directions() {
1183        let db = timed_sample();
1184        assert_eq!(db.tx_instant(1), Some(1_000));
1185        assert_eq!(db.tx_instant(2), Some(2_000));
1186        assert_eq!(db.tx_instant(3), None);
1187        // Exactly on a commit, before the first, between two, and after the last.
1188        assert_eq!(db.t_at_instant(1_000), 1);
1189        assert_eq!(db.t_at_instant(999), 0);
1190        assert_eq!(db.t_at_instant(1_999), 1);
1191        assert_eq!(db.t_at_instant(9_999), 2);
1192    }
1193
1194    #[test]
1195    fn instant_named_views_match_their_basis_named_equivalents() {
1196        let db = timed_sample();
1197        assert_eq!(db.as_of_instant(1_500).datoms(), db.as_of(1).datoms());
1198        assert_eq!(db.since_instant(1_500).datoms(), db.since(1).datoms());
1199        // An instant older than the database is the empty value; `since` that
1200        // same instant is therefore everything.
1201        assert!(db.as_of_instant(0).datoms().is_empty());
1202        assert_eq!(db.since_instant(0).datoms(), db.since(0).datoms());
1203    }
1204
1205    #[test]
1206    fn transaction_time_is_queryable_data() {
1207        let db = timed_sample();
1208        let instants: Vec<_> = db
1209            .datoms_for_attribute(bootstrap::TX_INSTANT)
1210            .map(|datom| (datom.e, datom.v.clone()))
1211            .collect();
1212        assert_eq!(
1213            instants,
1214            vec![
1215                (tx_entity(1), Value::Instant(1_000)),
1216                (tx_entity(2), Value::Instant(2_000)),
1217            ]
1218        );
1219        // Indexed, so an instant range is an AVET seek rather than a scan.
1220        assert!(avet_covered(db.schema(), bootstrap::TX_INSTANT));
1221    }
1222
1223    #[test]
1224    fn derived_views_keep_the_whole_transaction_time_correspondence() {
1225        // Resolution must not depend on which view names the instant: a
1226        // `since` view hides older datoms, but the instants they were
1227        // committed at still name the same transactions.
1228        let db = timed_sample();
1229        for view in [db.as_of(1), db.since(2), db.history()] {
1230            assert_eq!(view.tx_instant(1), Some(1_000));
1231            assert_eq!(view.t_at_instant(2_500), 2);
1232        }
1233    }
1234
1235    #[test]
1236    fn an_instant_supplied_with_the_datoms_wins_over_the_replayed_one() {
1237        // A log record written by a transactor that already materialized the
1238        // datom must not gain a second, contradictory one.
1239        let db = Db::new(schema()).with_transaction_at(
1240            1,
1241            5_000,
1242            &[
1243                datom(1, 1, Value::Str("alice".into()), 1, true),
1244                bootstrap::tx_instant_datom(1, 1_000),
1245            ],
1246        );
1247        assert_eq!(db.values(tx_entity(1), bootstrap::TX_INSTANT).len(), 1);
1248        assert_eq!(db.tx_instant(1), Some(1_000));
1249    }
1250
1251    #[test]
1252    fn snapshot_ignores_tx_instant_values_on_non_transaction_entities() {
1253        let user = entity(1);
1254        let db = Db::from_current_snapshot(
1255            1,
1256            schema(),
1257            Idents::default(),
1258            KeywordInterner::default(),
1259            vec![
1260                bootstrap::tx_instant_datom(1, 1_000),
1261                Datom {
1262                    e: user,
1263                    a: bootstrap::TX_INSTANT,
1264                    v: Value::Instant(9_000),
1265                    tx: tx_entity(1),
1266                    added: true,
1267                },
1268            ],
1269        );
1270        assert_eq!(db.tx_instant(1), Some(1_000));
1271    }
1272
1273    #[test]
1274    fn tx_range_groups_by_transaction() {
1275        let ranged = sample().tx_range(2, None);
1276        assert_eq!(ranged.len(), 1);
1277        assert_eq!(ranged[0].0, 2);
1278        assert_eq!(ranged[0].1.len(), 3);
1279    }
1280
1281    #[test]
1282    fn incremental_indexes_match_rebuilt_indexes() {
1283        let base = Db::new(schema());
1284        let tx1 = [datom(1, 1, Value::Str("a".into()), 1, true)];
1285        let tx2 = [
1286            datom(1, 1, Value::Str("a".into()), 2, false),
1287            datom(1, 1, Value::Str("b".into()), 2, true),
1288        ];
1289        // Force the parent cache so with_transaction derives incrementally.
1290        let warm = base.with_transaction(1, &tx1);
1291        let _ = warm.datoms();
1292        let incremental = warm.with_transaction(2, &tx2);
1293        let cold = base.with_transaction(1, &tx1).with_transaction(2, &tx2);
1294        assert_eq!(incremental.datoms(), cold.datoms());
1295    }
1296
1297    #[test]
1298    fn with_transaction_leaves_parent_value_unchanged() {
1299        // `recorded` is a persistent vector appended via copy-on-write; a
1300        // derived value must never mutate the log or indexes still observed
1301        // through the parent (e.g. `db_before` in a `TxReport`).
1302        let parent = Db::new(schema())
1303            .with_transaction(1, &[datom(1, 1, Value::Str("alice".into()), 1, true)]);
1304        // Materialize the parent's indexes so the child derives incrementally.
1305        let parent_datoms = parent.datoms();
1306        let parent_recorded = parent.recorded_len();
1307
1308        let child = parent.with_transaction(
1309            2,
1310            &[
1311                datom(1, 1, Value::Str("alice".into()), 2, false),
1312                datom(1, 1, Value::Str("alicia".into()), 2, true),
1313            ],
1314        );
1315
1316        // Parent is frozen at its own basis, log length, and live facts.
1317        assert_eq!(parent.basis_t(), 1);
1318        assert_eq!(parent.recorded_len(), parent_recorded);
1319        assert_eq!(parent.datoms(), parent_datoms);
1320        assert_eq!(
1321            parent.values(entity(1), attr(1)),
1322            vec![Value::Str("alice".into())]
1323        );
1324        // Child reflects the new transaction.
1325        assert_eq!(child.basis_t(), 2);
1326        assert_eq!(
1327            child.values(entity(1), attr(1)),
1328            vec![Value::Str("alicia".into())]
1329        );
1330    }
1331
1332    #[test]
1333    fn planner_stats_count_attributes() {
1334        let stats_owner = sample();
1335        let stats = stats_owner.planner_stats();
1336        assert_eq!(stats.total_datoms, 3);
1337        assert_eq!(stats.per_attr[&attr(1)].count, 1);
1338        assert!(stats.estimate(false, Some(attr(1)), false) <= stats.total_datoms);
1339    }
1340}