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//! Transaction time is data: every committed transaction asserts
11//! `:db/txInstant` on its transaction entity (see [`bootstrap`]), so views can
12//! also be named by wall clock ([`Db::as_of_instant`], [`Db::since_instant`]).
13
14pub mod bootstrap;
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::sync::{Arc, OnceLock};
18
19use corium_core::{
20    AttrId, Cardinality, Datom, EntityId, IndexOrder, Keyword, KeywordInterner, Partition, Schema,
21    Unique, Value, encoding::Encodable,
22};
23use rpds::{RedBlackTreeMapSync, VectorSync};
24
25/// The first user-assignable sequence number. Lower ids are reserved for bootstrap data.
26pub const FIRST_USER_ID: u64 = 1_000;
27
28/// Time-view selector for a database value (see `docs/design/time-model.md`).
29#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
30pub enum DbView {
31    /// Live facts as of the basis transaction.
32    #[default]
33    Current,
34    /// Facts as they stood at basis `t` (inclusive).
35    AsOf(u64),
36    /// Only live facts added after `t` (exclusive).
37    Since(u64),
38    /// Every assertion and retraction ever recorded, except `:db/noHistory` attributes.
39    History,
40}
41
42/// Registry of `:db/ident` names for entities (attributes chiefly).
43#[derive(Clone, Debug, Default)]
44pub struct Idents {
45    by_keyword: BTreeMap<Keyword, EntityId>,
46    by_id: BTreeMap<EntityId, Keyword>,
47}
48
49impl Idents {
50    /// Registers an ident for an entity.
51    pub fn insert(&mut self, keyword: Keyword, id: EntityId) {
52        self.by_id.insert(id, keyword.clone());
53        self.by_keyword.insert(keyword, id);
54    }
55
56    /// Resolves a keyword to its entity id.
57    #[must_use]
58    pub fn entid(&self, keyword: &Keyword) -> Option<EntityId> {
59        self.by_keyword.get(keyword).copied()
60    }
61
62    /// Resolves an entity id back to its ident.
63    #[must_use]
64    pub fn ident(&self, id: EntityId) -> Option<&Keyword> {
65        self.by_id.get(&id)
66    }
67
68    /// Iterates all registered idents in keyword order.
69    pub fn iter(&self) -> impl Iterator<Item = (&Keyword, &EntityId)> {
70        self.by_keyword.iter()
71    }
72}
73
74/// Per-attribute statistics driving planner selectivity estimates.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76pub struct AttrStats {
77    /// Datoms carrying this attribute in the view.
78    pub count: usize,
79    /// Distinct values for this attribute.
80    pub distinct_values: usize,
81    /// Distinct entities carrying this attribute.
82    pub distinct_entities: usize,
83}
84
85/// Whole-view statistics for the query planner.
86#[derive(Clone, Debug, Default)]
87pub struct PlannerStats {
88    /// Statistics per attribute.
89    pub per_attr: BTreeMap<AttrId, AttrStats>,
90    /// Total datoms in the view.
91    pub total_datoms: usize,
92    /// Distinct entities in the view.
93    pub entity_count: usize,
94}
95
96impl PlannerStats {
97    /// Estimated datoms matched by a scan with the given bound components.
98    #[must_use]
99    pub fn estimate(&self, e_bound: bool, a: Option<AttrId>, v_bound: bool) -> usize {
100        let attr = a.and_then(|a| self.per_attr.get(&a));
101        match (e_bound, attr) {
102            // Bound entity: at most the entity's datoms; refine by attribute.
103            (true, Some(stats)) => (stats.count / stats.distinct_entities.max(1)).max(1),
104            (true, None) => (self.total_datoms / self.entity_count.max(1)).max(1),
105            (false, Some(stats)) if v_bound => (stats.count / stats.distinct_values.max(1)).max(1),
106            (false, Some(stats)) => stats.count.max(1),
107            // Unknown attribute constant: nothing will match.
108            (false, None) if a.is_some() => 1,
109            (false, None) => self.total_datoms.max(1),
110        }
111    }
112}
113
114/// Covering index for one order.
115///
116/// A persistent (structurally shared) ordered map: cloning is O(1) and shares
117/// every unchanged node with the parent, so applying a transaction to a
118/// materialized index derives a new map that copies only the O(log n) nodes on
119/// the touched paths instead of the whole tree. This is what keeps
120/// `with_transaction` — and the per-operation folding inside `corium-tx` —
121/// from re-cloning the entire index on every write.
122type Index = RedBlackTreeMapSync<Vec<u8>, Datom>;
123
124const ORDERS: [IndexOrder; 4] = [
125    IndexOrder::Eavt,
126    IndexOrder::Aevt,
127    IndexOrder::Avet,
128    IndexOrder::Vaet,
129];
130
131const fn slot(order: IndexOrder) -> usize {
132    match order {
133        IndexOrder::Eavt => 0,
134        IndexOrder::Aevt => 1,
135        IndexOrder::Avet => 2,
136        IndexOrder::Vaet => 3,
137    }
138}
139
140/// Builds the encoded key prefix for a partial datom in one index order.
141///
142/// Components are consumed in the index's component order and encoding stops
143/// at the first missing component, so the result is a proper range prefix.
144#[must_use]
145pub fn key_prefix(
146    order: IndexOrder,
147    e: Option<EntityId>,
148    a: Option<AttrId>,
149    v: Option<&Value>,
150) -> Vec<u8> {
151    enum C {
152        E,
153        A,
154        V,
155    }
156    let components = match order {
157        IndexOrder::Eavt => [C::E, C::A, C::V],
158        IndexOrder::Aevt => [C::A, C::E, C::V],
159        IndexOrder::Avet => [C::A, C::V, C::E],
160        IndexOrder::Vaet => [C::V, C::A, C::E],
161    };
162    let mut out = Vec::new();
163    for component in components {
164        match component {
165            C::E => match e {
166                Some(e) => e.encode_into(&mut out),
167                None => break,
168            },
169            C::A => match a {
170                Some(a) => a.encode_into(&mut out),
171                None => break,
172            },
173            C::V => match v {
174                Some(v) => v.encode_into(&mut out),
175                None => break,
176            },
177        }
178    }
179    out
180}
181
182/// The `t` ↔ `:db/txInstant` correspondence for the transactions a value has
183/// seen, in both directions.
184///
185/// Both maps are persistent, so carrying them on every database value costs
186/// one O(log n) insert per transaction and nothing per clone. They are a
187/// property of the recorded transactions rather than of a time view, so a
188/// derived view (`as-of`, `since`, `history`) keeps the whole correspondence:
189/// naming a view by instant means the same thing whatever view you start from.
190///
191/// `:db/txInstant` is monotone by construction (the transactor stamps
192/// `max(now, last + 1)`), so ordering by instant and ordering by `t` agree.
193#[derive(Clone, Debug, Default)]
194pub struct TxInstants {
195    by_t: RedBlackTreeMapSync<u64, i64>,
196    by_instant: RedBlackTreeMapSync<i64, u64>,
197}
198
199impl TxInstants {
200    fn record(&mut self, t: u64, instant: i64) {
201        self.by_t.insert_mut(t, instant);
202        // Equal instants can only come from a log written without the
203        // monotonicity rule; keeping the highest `t` makes resolution pick the
204        // last transaction that shares an instant, matching `as-of`'s
205        // inclusive semantics.
206        if self.by_instant.get(&instant).is_none_or(|held| *held < t) {
207            self.by_instant.insert_mut(instant, t);
208        }
209    }
210
211    /// The commit instant of transaction `t`.
212    #[must_use]
213    pub fn instant(&self, t: u64) -> Option<i64> {
214        self.by_t.get(&t).copied()
215    }
216
217    /// The latest `t` committed at or before `instant`, or zero when no known
218    /// transaction is that old (the basis of an empty database).
219    #[must_use]
220    pub fn t_at(&self, instant: i64) -> u64 {
221        self.by_instant
222            .range(..=instant)
223            .next_back()
224            .map_or(0, |(_, t)| *t)
225    }
226
227    /// Number of transactions with a known instant.
228    #[must_use]
229    pub fn len(&self) -> usize {
230        self.by_t.size()
231    }
232
233    /// Whether no transaction instant is known.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        self.by_t.is_empty()
237    }
238}
239
240/// An immutable value of a database at one basis transaction and time view.
241#[derive(Clone, Debug, Default)]
242pub struct Db {
243    basis_t: u64,
244    schema: Schema,
245    recorded: VectorSync<Datom>,
246    idents: Arc<Idents>,
247    interner: Arc<KeywordInterner>,
248    view: DbView,
249    instants: TxInstants,
250    indexes: Arc<OnceLock<[Index; 4]>>,
251    stats: Arc<OnceLock<PlannerStats>>,
252}
253
254impl Db {
255    /// Creates an empty database with the supplied schema.
256    ///
257    /// The engine's own attributes ([`bootstrap`]) are installed over
258    /// `schema`, so every database understands `:db/txInstant` whether or not
259    /// the caller's schema mentions it.
260    #[must_use]
261    pub fn new(mut schema: Schema) -> Self {
262        bootstrap::install_schema(&mut schema);
263        Self {
264            schema,
265            ..Self::default()
266        }
267    }
268
269    /// Creates a current database value from a published EAVT snapshot.
270    ///
271    /// `datoms` must be the live facts at `basis_t`. Their original
272    /// transaction ids are retained, but transactions before `basis_t` that
273    /// no longer contribute a live fact are not reconstructed. This makes
274    /// the value suitable for current queries and for applying the log tail;
275    /// complete historical views still require replaying the full log.
276    ///
277    /// `:db/txInstant` datoms are live facts (nothing retracts them), so a
278    /// snapshot carries the transaction-time correspondence for every
279    /// transaction it covers — unless it was published before the engine
280    /// recorded instants as datoms, in which case instant-named views resolve
281    /// only within the replayed tail.
282    #[must_use]
283    pub fn from_current_snapshot(
284        basis_t: u64,
285        mut schema: Schema,
286        mut idents: Idents,
287        interner: KeywordInterner,
288        datoms: Vec<Datom>,
289    ) -> Self {
290        bootstrap::install(&mut schema, &mut idents);
291        let mut instants = TxInstants::default();
292        for datom in &datoms {
293            if let Datom {
294                e,
295                a,
296                v: Value::Instant(instant),
297                tx,
298                added: true,
299                ..
300            } = datom
301                && *a == bootstrap::TX_INSTANT
302                && *e == *tx
303                && tx.partition() == Partition::Tx as u32
304            {
305                instants.record(tx.sequence(), *instant);
306            }
307        }
308        Self {
309            basis_t,
310            schema,
311            recorded: datoms.into_iter().collect(),
312            idents: Arc::new(idents),
313            interner: Arc::new(interner),
314            view: DbView::Current,
315            instants,
316            indexes: Arc::new(OnceLock::new()),
317            stats: Arc::new(OnceLock::new()),
318        }
319    }
320
321    /// Attaches ident and keyword naming registries, returning the named value.
322    #[must_use]
323    pub fn with_naming(mut self, mut idents: Idents, interner: KeywordInterner) -> Self {
324        bootstrap::install(&mut self.schema, &mut idents);
325        self.idents = Arc::new(idents);
326        self.interner = Arc::new(interner);
327        self
328    }
329
330    /// Current transaction basis.
331    #[must_use]
332    pub const fn basis_t(&self) -> u64 {
333        self.basis_t
334    }
335
336    /// Schema at this basis.
337    #[must_use]
338    pub const fn schema(&self) -> &Schema {
339        &self.schema
340    }
341
342    /// Ident registry.
343    #[must_use]
344    pub fn idents(&self) -> &Idents {
345        &self.idents
346    }
347
348    /// Keyword interner used by keyword values in this database.
349    #[must_use]
350    pub fn interner(&self) -> &KeywordInterner {
351        &self.interner
352    }
353
354    /// The time view this value presents.
355    #[must_use]
356    pub const fn view(&self) -> DbView {
357        self.view
358    }
359
360    /// Every recorded assertion and retraction, in transaction order.
361    pub fn recorded_datoms(&self) -> impl Iterator<Item = &Datom> {
362        self.recorded.iter()
363    }
364
365    /// Number of recorded assertions and retractions.
366    #[must_use]
367    pub fn recorded_len(&self) -> usize {
368        self.recorded.len()
369    }
370
371    /// Returns the as-of view at basis `t`: facts as they stood then.
372    #[must_use]
373    pub fn as_of(&self, t: u64) -> Self {
374        self.with_view(DbView::AsOf(t))
375    }
376
377    /// Returns the since view: only live facts added after `t`.
378    #[must_use]
379    pub fn since(&self, t: u64) -> Self {
380        self.with_view(DbView::Since(t))
381    }
382
383    /// Returns the history view: all assertions and retractions ever.
384    #[must_use]
385    pub fn history(&self) -> Self {
386        self.with_view(DbView::History)
387    }
388
389    /// The `t` ↔ `:db/txInstant` correspondence recorded by this value.
390    #[must_use]
391    pub const fn instants(&self) -> &TxInstants {
392        &self.instants
393    }
394
395    /// The commit instant of transaction `t`, when this value has seen it.
396    #[must_use]
397    pub fn tx_instant(&self, t: u64) -> Option<i64> {
398        self.instants.instant(t)
399    }
400
401    /// The latest basis committed at or before `instant` (Unix milliseconds).
402    ///
403    /// Zero when no known transaction is that old, so `as-of` an instant
404    /// before the database existed is the empty value and `since` that instant
405    /// is everything — the same interpretation Datomic gives out-of-range
406    /// instants.
407    #[must_use]
408    pub fn t_at_instant(&self, instant: i64) -> u64 {
409        self.instants.t_at(instant)
410    }
411
412    /// Returns the as-of view at wall-clock `instant` (Unix milliseconds):
413    /// facts as they stood after the last transaction committed at or before
414    /// it.
415    #[must_use]
416    pub fn as_of_instant(&self, instant: i64) -> Self {
417        self.as_of(self.t_at_instant(instant))
418    }
419
420    /// Returns the since view at wall-clock `instant` (Unix milliseconds):
421    /// only live facts added after the last transaction committed at or before
422    /// it.
423    #[must_use]
424    pub fn since_instant(&self, instant: i64) -> Self {
425        self.since(self.t_at_instant(instant))
426    }
427
428    fn with_view(&self, view: DbView) -> Self {
429        if view == self.view {
430            return self.clone();
431        }
432        Self {
433            view,
434            indexes: Arc::new(OnceLock::new()),
435            stats: Arc::new(OnceLock::new()),
436            ..self.clone()
437        }
438    }
439
440    /// Groups recorded datoms by transaction over the half-open range `[start, end)`.
441    #[must_use]
442    pub fn tx_range(&self, start: u64, end: Option<u64>) -> Vec<(u64, Vec<Datom>)> {
443        let mut by_t: BTreeMap<u64, Vec<Datom>> = BTreeMap::new();
444        for datom in &self.recorded {
445            let t = datom.tx.sequence();
446            if t >= start && end.is_none_or(|end| t < end) {
447                by_t.entry(t).or_default().push(datom.clone());
448            }
449        }
450        by_t.into_iter().collect()
451    }
452
453    /// Returns this view's facts, deterministically ordered by EAVT.
454    #[must_use]
455    pub fn datoms(&self) -> Vec<Datom> {
456        self.datoms_at(IndexOrder::Eavt).cloned().collect()
457    }
458
459    /// Iterates this view's datoms in one index order.
460    ///
461    /// AVET covers only indexed/unique attributes and VAET only reference
462    /// values, mirroring Datomic's covering-index composition.
463    pub fn datoms_at(&self, order: IndexOrder) -> impl Iterator<Item = &Datom> {
464        self.indexes()[slot(order)].values()
465    }
466
467    /// Iterates this view's datoms for one attribute in AEVT order.
468    ///
469    /// Unlike AVET, AEVT covers every installed attribute. Callers can use
470    /// this as the fallback for attribute predicates that do not have AVET
471    /// coverage.
472    pub fn datoms_for_attribute(&self, a: AttrId) -> impl Iterator<Item = &Datom> {
473        let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
474        self.indexes()[slot(IndexOrder::Aevt)]
475            .range(prefix.clone()..)
476            .take_while(move |(key, _)| key.starts_with(&prefix))
477            .map(|(_, datom)| datom)
478    }
479
480    /// Iterates datoms whose key in `order` starts with `prefix`.
481    pub fn datoms_prefix<'a>(
482        &'a self,
483        order: IndexOrder,
484        prefix: &'a [u8],
485    ) -> impl Iterator<Item = &'a Datom> {
486        self.indexes()[slot(order)]
487            .range(prefix.to_vec()..)
488            .take_while(move |(key, _)| key.starts_with(prefix))
489            .map(|(_, datom)| datom)
490    }
491
492    /// Iterates datoms in `order` starting from the first key at or after `start`.
493    pub fn seek_datoms<'a>(
494        &'a self,
495        order: IndexOrder,
496        start: &[u8],
497    ) -> impl Iterator<Item = &'a Datom> {
498        self.indexes()[slot(order)]
499            .range(start.to_vec()..)
500            .map(|(_, datom)| datom)
501    }
502
503    /// Iterates the AVET index for `a` over the value range `[start, end)`.
504    ///
505    /// Only indexed/unique attributes appear in AVET.
506    pub fn index_range<'a>(
507        &'a self,
508        a: AttrId,
509        start: Option<&Value>,
510        end: Option<&'a Value>,
511    ) -> impl Iterator<Item = &'a Datom> {
512        let a_prefix = key_prefix(IndexOrder::Avet, None, Some(a), None);
513        let start_key = key_prefix(IndexOrder::Avet, None, Some(a), start);
514        self.indexes()[slot(IndexOrder::Avet)]
515            .range(start_key..)
516            .take_while(move |(key, _)| key.starts_with(&a_prefix))
517            .map(|(_, datom)| datom)
518            .take_while(move |datom| end.is_none_or(|end| datom.v < *end))
519    }
520
521    /// Current values for an entity/attribute pair.
522    #[must_use]
523    pub fn values(&self, e: EntityId, a: AttrId) -> Vec<Value> {
524        let prefix = key_prefix(IndexOrder::Eavt, Some(e), Some(a), None);
525        self.datoms_prefix(IndexOrder::Eavt, &prefix)
526            .map(|datom| datom.v.clone())
527            .collect()
528    }
529
530    /// Resolves a unique attribute/value pair to its entity.
531    #[must_use]
532    pub fn lookup(&self, a: AttrId, v: &Value) -> Option<EntityId> {
533        if avet_covered(&self.schema, a) {
534            let prefix = key_prefix(IndexOrder::Avet, None, Some(a), Some(v));
535            self.datoms_prefix(IndexOrder::Avet, &prefix)
536                .next()
537                .map(|datom| datom.e)
538        } else {
539            let prefix = key_prefix(IndexOrder::Aevt, None, Some(a), None);
540            self.datoms_prefix(IndexOrder::Aevt, &prefix)
541                .find(|datom| datom.v == *v)
542                .map(|datom| datom.e)
543        }
544    }
545
546    /// Applies a committed record, returning a new database value.
547    ///
548    /// Only meaningful for the current view; time views are read-only.
549    ///
550    /// A `:db/txInstant` assertion on the transaction entity — which every
551    /// commit path materializes — is picked up as this transaction's place on
552    /// the wall clock.
553    #[must_use]
554    pub fn with_transaction(&self, t: u64, datoms: &[Datom]) -> Self {
555        match bootstrap::asserted_instant(t, datoms) {
556            Some(instant) => self.with_transaction_at(t, instant, datoms),
557            None => self.apply_transaction(t, datoms),
558        }
559    }
560
561    /// Applies a committed record whose commit instant is known separately,
562    /// materializing the `:db/txInstant` datom when `datoms` lacks one.
563    ///
564    /// Replay paths use this so a log written before Corium recorded instants
565    /// as datoms still yields instant-named views: the record's timestamp
566    /// field becomes the datom it would carry today.
567    #[must_use]
568    pub fn with_transaction_at(&self, t: u64, instant: i64, datoms: &[Datom]) -> Self {
569        let asserted = bootstrap::asserted_instant(t, datoms);
570        let mut next = if asserted.is_some() {
571            self.apply_transaction(t, datoms)
572        } else {
573            let mut with_instant = Vec::with_capacity(datoms.len() + 1);
574            with_instant.extend_from_slice(datoms);
575            with_instant.push(bootstrap::tx_instant_datom(t, instant));
576            self.apply_transaction(t, &with_instant)
577        };
578        next.instants.record(t, asserted.unwrap_or(instant));
579        next
580    }
581
582    fn apply_transaction(&self, t: u64, datoms: &[Datom]) -> Self {
583        debug_assert!(
584            self.view == DbView::Current,
585            "with_transaction applies only to the current view"
586        );
587        let mut next = self.clone();
588        next.basis_t = t;
589        // `recorded` is a persistent vector: this clone shared its whole spine
590        // with the parent, and appending copies only the O(log n) nodes on the
591        // tail path — never the entire log, even while `db_before` keeps the
592        // parent alive.
593        for datom in datoms {
594            next.recorded.push_back_mut(datom.clone());
595        }
596        next.indexes = Arc::new(OnceLock::new());
597        next.stats = Arc::new(OnceLock::new());
598        // Derive indexes incrementally when the parent already built them, so
599        // transaction pipelines don't refold the whole history per operation.
600        if let Some(parent) = self.indexes.get() {
601            let mut derived = parent.clone();
602            apply_current(&mut derived, datoms.iter(), &self.schema);
603            let _ = next.indexes.set(derived);
604        }
605        next
606    }
607
608    /// Computes basic statistics over this view's facts.
609    #[must_use]
610    pub fn stats(&self) -> DbStats {
611        let planner = self.planner_stats();
612        DbStats {
613            datoms: planner.total_datoms,
614            entities: planner.entity_count,
615            attributes: planner.per_attr.len(),
616        }
617    }
618
619    /// Planner statistics for this view, built lazily and cached.
620    #[must_use]
621    pub fn planner_stats(&self) -> &PlannerStats {
622        self.stats.get_or_init(|| {
623            let mut stats = PlannerStats::default();
624            let mut values: BTreeMap<AttrId, BTreeSet<&Value>> = BTreeMap::new();
625            let mut attr_entities: BTreeMap<AttrId, BTreeSet<EntityId>> = BTreeMap::new();
626            let mut entities: BTreeSet<EntityId> = BTreeSet::new();
627            for datom in self.datoms_at(IndexOrder::Eavt) {
628                stats.total_datoms += 1;
629                stats.per_attr.entry(datom.a).or_default().count += 1;
630                values.entry(datom.a).or_default().insert(&datom.v);
631                attr_entities.entry(datom.a).or_default().insert(datom.e);
632                entities.insert(datom.e);
633            }
634            for (a, entry) in &mut stats.per_attr {
635                entry.distinct_values = values.get(a).map_or(0, BTreeSet::len);
636                entry.distinct_entities = attr_entities.get(a).map_or(0, BTreeSet::len);
637            }
638            stats.entity_count = entities.len();
639            stats
640        })
641    }
642
643    fn indexes(&self) -> &[Index; 4] {
644        self.indexes.get_or_init(|| {
645            let mut indexes: [Index; 4] = Default::default();
646            match self.view {
647                DbView::History => {
648                    for datom in &self.recorded {
649                        if self.schema.get(datom.a).is_some_and(|a| a.no_history) {
650                            continue;
651                        }
652                        insert_datom(&mut indexes, datom, &self.schema, true);
653                    }
654                }
655                DbView::Current | DbView::AsOf(_) | DbView::Since(_) => {
656                    let cutoff = match self.view {
657                        DbView::AsOf(t) => Some(t),
658                        _ => None,
659                    };
660                    let filtered = self
661                        .recorded
662                        .iter()
663                        .filter(|d| cutoff.is_none_or(|t| d.tx.sequence() <= t));
664                    apply_current(&mut indexes, filtered, &self.schema);
665                    if let DbView::Since(t) = self.view {
666                        for index in &mut indexes {
667                            *index = index
668                                .iter()
669                                .filter(|(_, datom)| datom.tx.sequence() > t)
670                                .map(|(key, datom)| (key.clone(), datom.clone()))
671                                .collect();
672                        }
673                    }
674                }
675            }
676            indexes
677        })
678    }
679}
680
681/// Folds assertions/retractions into current-view indexes.
682///
683/// Current views key entries by components only (no transaction suffix):
684/// at most one live datom exists per `(e, a, v)`, and retractions must
685/// erase the assertion regardless of which transactions produced them.
686fn apply_current<'a>(
687    indexes: &mut [Index; 4],
688    datoms: impl Iterator<Item = &'a Datom>,
689    schema: &Schema,
690) {
691    for datom in datoms {
692        if datom.added {
693            insert_datom(indexes, datom, schema, false);
694        } else {
695            for order in ORDERS {
696                if covered(schema, order, datom) {
697                    let key = key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v));
698                    indexes[slot(order)].remove_mut(&key);
699                }
700            }
701        }
702    }
703}
704
705fn insert_datom(indexes: &mut [Index; 4], datom: &Datom, schema: &Schema, with_tx: bool) {
706    for order in ORDERS {
707        if covered(schema, order, datom) {
708            let key = if with_tx {
709                datom.key(order)
710            } else {
711                key_prefix(order, Some(datom.e), Some(datom.a), Some(&datom.v))
712            };
713            indexes[slot(order)].insert_mut(key, datom.clone());
714        }
715    }
716}
717
718fn covered(schema: &Schema, order: IndexOrder, datom: &Datom) -> bool {
719    match order {
720        IndexOrder::Eavt | IndexOrder::Aevt => true,
721        IndexOrder::Avet => avet_covered(schema, datom.a),
722        IndexOrder::Vaet => matches!(datom.v, Value::Ref(_)),
723    }
724}
725
726/// Whether the attribute participates in the AVET covering index.
727#[must_use]
728pub fn avet_covered(schema: &Schema, a: AttrId) -> bool {
729    schema
730        .get(a)
731        .is_some_and(|attr| attr.indexed || attr.unique.is_some())
732}
733
734/// Counts over one database view.
735#[derive(Clone, Copy, Debug, Eq, PartialEq)]
736pub struct DbStats {
737    /// Facts in the view.
738    pub datoms: usize,
739    /// Entities having at least one fact in the view.
740    pub entities: usize,
741    /// Attributes used by facts in the view.
742    pub attributes: usize,
743}
744
745/// Convenience constructor for schema attributes used during bootstrap/tests.
746#[must_use]
747pub const fn attribute(
748    id: u64,
749    value_type: corium_core::ValueType,
750    cardinality: Cardinality,
751    unique: Option<Unique>,
752) -> corium_core::Attribute {
753    corium_core::Attribute {
754        id: EntityId::new(Partition::Db as u32, id),
755        value_type,
756        cardinality,
757        unique,
758        is_component: false,
759        indexed: unique.is_some(),
760        no_history: false,
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use corium_core::ValueType;
768
769    fn schema() -> Schema {
770        let mut schema = Schema::default();
771        schema.insert(attribute(1, ValueType::Str, Cardinality::One, None));
772        schema.insert(attribute(
773            2,
774            ValueType::Long,
775            Cardinality::One,
776            Some(Unique::Identity),
777        ));
778        schema.insert(attribute(3, ValueType::Ref, Cardinality::Many, None));
779        schema
780    }
781
782    fn attr(id: u64) -> AttrId {
783        EntityId::new(Partition::Db as u32, id)
784    }
785
786    fn entity(id: u64) -> EntityId {
787        EntityId::new(Partition::User as u32, id)
788    }
789
790    fn tx_entity(t: u64) -> EntityId {
791        EntityId::new(Partition::Tx as u32, t)
792    }
793
794    fn datom(e: u64, a: u64, v: Value, t: u64, added: bool) -> Datom {
795        Datom {
796            e: entity(e),
797            a: attr(a),
798            v,
799            tx: tx_entity(t),
800            added,
801        }
802    }
803
804    fn sample() -> Db {
805        Db::new(schema())
806            .with_transaction(
807                1,
808                &[
809                    datom(1, 1, Value::Str("alice".into()), 1, true),
810                    datom(1, 2, Value::Long(7), 1, true),
811                ],
812            )
813            .with_transaction(
814                2,
815                &[
816                    datom(1, 1, Value::Str("alice".into()), 2, false),
817                    datom(1, 1, Value::Str("alicia".into()), 2, true),
818                    datom(2, 3, Value::Ref(entity(1)), 2, true),
819                ],
820            )
821    }
822
823    #[test]
824    fn current_view_folds_retractions() {
825        let db = sample();
826        assert_eq!(
827            db.values(entity(1), attr(1)),
828            vec![Value::Str("alicia".into())]
829        );
830        assert_eq!(db.stats().datoms, 3);
831    }
832
833    #[test]
834    fn attribute_scan_uses_complete_aevt_coverage() {
835        let db = sample();
836        let datoms = db.datoms_for_attribute(attr(1)).collect::<Vec<_>>();
837        assert_eq!(datoms.len(), 1);
838        assert_eq!(datoms[0].v, Value::Str("alicia".into()));
839        assert!(datoms.iter().all(|datom| datom.a == attr(1)));
840    }
841
842    #[test]
843    fn as_of_reconstructs_past_basis() {
844        let db = sample().as_of(1);
845        assert_eq!(
846            db.values(entity(1), attr(1)),
847            vec![Value::Str("alice".into())]
848        );
849        assert_eq!(db.stats().datoms, 2);
850    }
851
852    #[test]
853    fn since_excludes_older_live_facts() {
854        let db = sample().since(1);
855        // The long asserted at t=1 is invisible; the renamed string is visible.
856        assert_eq!(db.values(entity(1), attr(2)), Vec::<Value>::new());
857        assert_eq!(
858            db.values(entity(1), attr(1)),
859            vec![Value::Str("alicia".into())]
860        );
861    }
862
863    #[test]
864    fn history_exposes_assertions_and_retractions() {
865        let db = sample().history();
866        let names: Vec<_> = db
867            .datoms_prefix(
868                IndexOrder::Eavt,
869                &key_prefix(IndexOrder::Eavt, Some(entity(1)), Some(attr(1)), None),
870            )
871            .map(|d| (d.v.clone(), d.added))
872            .collect();
873        assert_eq!(names.len(), 3);
874        assert!(names.contains(&(Value::Str("alice".into()), false)));
875    }
876
877    #[test]
878    fn avet_only_covers_indexed_attributes() {
879        let db = sample();
880        assert_eq!(db.datoms_at(IndexOrder::Avet).count(), 1);
881        assert_eq!(db.datoms_at(IndexOrder::Vaet).count(), 1);
882    }
883
884    #[test]
885    fn index_range_scans_value_bounds() {
886        let db = Db::new(schema()).with_transaction(
887            1,
888            &[
889                datom(1, 2, Value::Long(1), 1, true),
890                datom(2, 2, Value::Long(5), 1, true),
891                datom(3, 2, Value::Long(9), 1, true),
892            ],
893        );
894        let hits: Vec<_> = db
895            .index_range(attr(2), Some(&Value::Long(2)), Some(&Value::Long(9)))
896            .map(|d| d.e)
897            .collect();
898        assert_eq!(hits, vec![entity(2)]);
899    }
900
901    /// The same two transactions as [`sample`], committed at known instants
902    /// the way the transactor commits them.
903    fn timed_sample() -> Db {
904        Db::new(schema())
905            .with_transaction_at(
906                1,
907                1_000,
908                &[datom(1, 1, Value::Str("alice".into()), 1, true)],
909            )
910            .with_transaction_at(
911                2,
912                2_000,
913                &[
914                    datom(1, 1, Value::Str("alice".into()), 2, false),
915                    datom(1, 1, Value::Str("alicia".into()), 2, true),
916                ],
917            )
918    }
919
920    #[test]
921    fn commit_instants_are_recorded_in_both_directions() {
922        let db = timed_sample();
923        assert_eq!(db.tx_instant(1), Some(1_000));
924        assert_eq!(db.tx_instant(2), Some(2_000));
925        assert_eq!(db.tx_instant(3), None);
926        // Exactly on a commit, before the first, between two, and after the last.
927        assert_eq!(db.t_at_instant(1_000), 1);
928        assert_eq!(db.t_at_instant(999), 0);
929        assert_eq!(db.t_at_instant(1_999), 1);
930        assert_eq!(db.t_at_instant(9_999), 2);
931    }
932
933    #[test]
934    fn instant_named_views_match_their_basis_named_equivalents() {
935        let db = timed_sample();
936        assert_eq!(db.as_of_instant(1_500).datoms(), db.as_of(1).datoms());
937        assert_eq!(db.since_instant(1_500).datoms(), db.since(1).datoms());
938        // An instant older than the database is the empty value; `since` that
939        // same instant is therefore everything.
940        assert!(db.as_of_instant(0).datoms().is_empty());
941        assert_eq!(db.since_instant(0).datoms(), db.since(0).datoms());
942    }
943
944    #[test]
945    fn transaction_time_is_queryable_data() {
946        let db = timed_sample();
947        let instants: Vec<_> = db
948            .datoms_for_attribute(bootstrap::TX_INSTANT)
949            .map(|datom| (datom.e, datom.v.clone()))
950            .collect();
951        assert_eq!(
952            instants,
953            vec![
954                (tx_entity(1), Value::Instant(1_000)),
955                (tx_entity(2), Value::Instant(2_000)),
956            ]
957        );
958        // Indexed, so an instant range is an AVET seek rather than a scan.
959        assert!(avet_covered(db.schema(), bootstrap::TX_INSTANT));
960    }
961
962    #[test]
963    fn derived_views_keep_the_whole_transaction_time_correspondence() {
964        // Resolution must not depend on which view names the instant: a
965        // `since` view hides older datoms, but the instants they were
966        // committed at still name the same transactions.
967        let db = timed_sample();
968        for view in [db.as_of(1), db.since(2), db.history()] {
969            assert_eq!(view.tx_instant(1), Some(1_000));
970            assert_eq!(view.t_at_instant(2_500), 2);
971        }
972    }
973
974    #[test]
975    fn an_instant_supplied_with_the_datoms_wins_over_the_replayed_one() {
976        // A log record written by a transactor that already materialized the
977        // datom must not gain a second, contradictory one.
978        let db = Db::new(schema()).with_transaction_at(
979            1,
980            5_000,
981            &[
982                datom(1, 1, Value::Str("alice".into()), 1, true),
983                bootstrap::tx_instant_datom(1, 1_000),
984            ],
985        );
986        assert_eq!(db.values(tx_entity(1), bootstrap::TX_INSTANT).len(), 1);
987        assert_eq!(db.tx_instant(1), Some(1_000));
988    }
989
990    #[test]
991    fn snapshot_ignores_tx_instant_values_on_non_transaction_entities() {
992        let user = entity(1);
993        let db = Db::from_current_snapshot(
994            1,
995            schema(),
996            Idents::default(),
997            KeywordInterner::default(),
998            vec![
999                bootstrap::tx_instant_datom(1, 1_000),
1000                Datom {
1001                    e: user,
1002                    a: bootstrap::TX_INSTANT,
1003                    v: Value::Instant(9_000),
1004                    tx: tx_entity(1),
1005                    added: true,
1006                },
1007            ],
1008        );
1009        assert_eq!(db.tx_instant(1), Some(1_000));
1010    }
1011
1012    #[test]
1013    fn tx_range_groups_by_transaction() {
1014        let ranged = sample().tx_range(2, None);
1015        assert_eq!(ranged.len(), 1);
1016        assert_eq!(ranged[0].0, 2);
1017        assert_eq!(ranged[0].1.len(), 3);
1018    }
1019
1020    #[test]
1021    fn incremental_indexes_match_rebuilt_indexes() {
1022        let base = Db::new(schema());
1023        let tx1 = [datom(1, 1, Value::Str("a".into()), 1, true)];
1024        let tx2 = [
1025            datom(1, 1, Value::Str("a".into()), 2, false),
1026            datom(1, 1, Value::Str("b".into()), 2, true),
1027        ];
1028        // Force the parent cache so with_transaction derives incrementally.
1029        let warm = base.with_transaction(1, &tx1);
1030        let _ = warm.datoms();
1031        let incremental = warm.with_transaction(2, &tx2);
1032        let cold = base.with_transaction(1, &tx1).with_transaction(2, &tx2);
1033        assert_eq!(incremental.datoms(), cold.datoms());
1034    }
1035
1036    #[test]
1037    fn with_transaction_leaves_parent_value_unchanged() {
1038        // `recorded` is a persistent vector appended via copy-on-write; a
1039        // derived value must never mutate the log or indexes still observed
1040        // through the parent (e.g. `db_before` in a `TxReport`).
1041        let parent = Db::new(schema())
1042            .with_transaction(1, &[datom(1, 1, Value::Str("alice".into()), 1, true)]);
1043        // Materialize the parent's indexes so the child derives incrementally.
1044        let parent_datoms = parent.datoms();
1045        let parent_recorded = parent.recorded_len();
1046
1047        let child = parent.with_transaction(
1048            2,
1049            &[
1050                datom(1, 1, Value::Str("alice".into()), 2, false),
1051                datom(1, 1, Value::Str("alicia".into()), 2, true),
1052            ],
1053        );
1054
1055        // Parent is frozen at its own basis, log length, and live facts.
1056        assert_eq!(parent.basis_t(), 1);
1057        assert_eq!(parent.recorded_len(), parent_recorded);
1058        assert_eq!(parent.datoms(), parent_datoms);
1059        assert_eq!(
1060            parent.values(entity(1), attr(1)),
1061            vec![Value::Str("alice".into())]
1062        );
1063        // Child reflects the new transaction.
1064        assert_eq!(child.basis_t(), 2);
1065        assert_eq!(
1066            child.values(entity(1), attr(1)),
1067            vec![Value::Str("alicia".into())]
1068        );
1069    }
1070
1071    #[test]
1072    fn planner_stats_count_attributes() {
1073        let stats_owner = sample();
1074        let stats = stats_owner.planner_stats();
1075        assert_eq!(stats.total_datoms, 3);
1076        assert_eq!(stats.per_attr[&attr(1)].count, 1);
1077        assert!(stats.estimate(false, Some(attr(1)), false) <= stats.total_datoms);
1078    }
1079}