Skip to main content

teaql_runtime/
entity_runtime.rs

1use std::any::{Any, TypeId};
2use std::borrow::Cow;
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::{Arc, Mutex, OnceLock, Weak};
5
6use teaql_core::{EntitySnapshot, MutationValues, SmartList, Value};
7
8/// The explicit load state of a relation stored in the runtime identity graph.
9///
10/// Reading this state never performs I/O. `NotLoaded` means exactly that the
11/// current query did not install a value for the relation; callers must issue
12/// an explicit query if they need it.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LoadedRelation {
15    Loaded,
16    Empty,
17    NotLoaded,
18}
19
20/// A borrowed view of a relation in the runtime identity graph.
21///
22/// `value()` is present for both `Loaded` and loaded-empty collection values.
23/// It is absent for a null to-one relation and for `NotLoaded`.
24#[derive(Debug, Clone, Copy)]
25pub struct RelationHandle<'a, T> {
26    state: LoadedRelation,
27    value: Option<&'a T>,
28}
29
30impl<'a, T> RelationHandle<'a, T> {
31    fn new(state: LoadedRelation, value: Option<&'a T>) -> Self {
32        Self { state, value }
33    }
34
35    pub fn state(&self) -> LoadedRelation {
36        self.state
37    }
38
39    pub fn value(&self) -> Option<&'a T> {
40        self.value
41    }
42
43    pub fn is_loaded(&self) -> bool {
44        self.state != LoadedRelation::NotLoaded
45    }
46
47    pub fn is_empty(&self) -> bool {
48        self.state == LoadedRelation::Empty
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct EntityKey {
54    pub entity: Cow<'static, str>,
55    pub id: Value,
56    id_key: EntityIdentityKey,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60enum EntityIdentityKey {
61    Null,
62    Bool(bool),
63    I64(i64),
64    U64(u64),
65    F64(u64),
66    Decimal(rust_decimal::Decimal),
67    Text(String),
68    Date(chrono::NaiveDate),
69    Timestamp(i64),
70    Other(String),
71}
72
73impl EntityKey {
74    pub fn new(entity: impl Into<String>, id: impl Into<Value>) -> Self {
75        let id = id.into();
76        Self {
77            entity: Cow::Owned(entity.into()),
78            id_key: entity_identity_key(&id),
79            id,
80        }
81    }
82
83    pub fn new_static(entity: &'static str, id: impl Into<Value>) -> Self {
84        let id = id.into();
85        Self {
86            entity: Cow::Borrowed(entity),
87            id_key: entity_identity_key(&id),
88            id,
89        }
90    }
91}
92
93impl PartialEq for EntityKey {
94    fn eq(&self, other: &Self) -> bool {
95        self.entity == other.entity && self.id_key == other.id_key
96    }
97}
98
99impl Eq for EntityKey {}
100
101impl PartialOrd for EntityKey {
102    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
103        Some(self.cmp(other))
104    }
105}
106
107impl Ord for EntityKey {
108    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
109        self.entity
110            .cmp(&other.entity)
111            .then_with(|| self.id_key.cmp(&other.id_key))
112    }
113}
114
115fn entity_identity_key(value: &Value) -> EntityIdentityKey {
116    match value {
117        Value::Null | Value::TypedNull(_) => EntityIdentityKey::Null,
118        Value::Bool(value) => EntityIdentityKey::Bool(*value),
119        Value::I64(value) => EntityIdentityKey::I64(*value),
120        Value::U64(value) => EntityIdentityKey::U64(*value),
121        Value::F64(value) => EntityIdentityKey::F64(value.to_bits()),
122        Value::Decimal(value) => EntityIdentityKey::Decimal(*value),
123        Value::Text(value) => EntityIdentityKey::Text(value.clone()),
124        Value::Json(value) => EntityIdentityKey::Other(format!("json:{value}")),
125        Value::Date(value) => EntityIdentityKey::Date(*value),
126        Value::Timestamp(value) => EntityIdentityKey::Timestamp(value.0),
127        Value::Object(_) => EntityIdentityKey::Other("object".to_owned()),
128        Value::List(_) => EntityIdentityKey::Other("list".to_owned()),
129    }
130}
131
132#[derive(Default)]
133pub struct EntityGraphBuilder {
134    tables: HashMap<TypeId, EntityTable>,
135    relation_lists: HashMap<RelationListKey, Box<dyn Any + Send + Sync>>,
136}
137
138type EntityTable = HashMap<u64, Box<dyn Any + Send + Sync>>;
139
140#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141struct RelationListKey {
142    owner_entity: String,
143    owner_id: u64,
144    relation: String,
145}
146
147impl EntityGraphBuilder {
148    pub fn install<T>(&mut self, id: u64, entity: T)
149    where
150        T: Any + Send + Sync,
151    {
152        self.tables
153            .entry(TypeId::of::<T>())
154            .or_default()
155            .insert(id, Box::new(entity));
156    }
157
158    pub fn entity_count(&self) -> usize {
159        self.tables.values().map(HashMap::len).sum()
160    }
161
162    pub fn install_relation_list<T>(
163        &mut self,
164        owner_entity: impl Into<String>,
165        owner_id: u64,
166        relation: impl Into<String>,
167        list: SmartList<T>,
168    ) where
169        T: Any + Send + Sync,
170    {
171        self.relation_lists.insert(
172            RelationListKey {
173                owner_entity: crate::canonical_id_space_entity(&owner_entity.into()),
174                owner_id,
175                relation: relation.into(),
176            },
177            Box::new(list),
178        );
179    }
180
181    pub fn install_relation_option<T>(
182        &mut self,
183        owner_entity: impl Into<String>,
184        owner_id: u64,
185        relation: impl Into<String>,
186        value: Option<T>,
187    ) where
188        T: Any + Send + Sync,
189    {
190        self.relation_lists.insert(
191            RelationListKey {
192                owner_entity: crate::canonical_id_space_entity(&owner_entity.into()),
193                owner_id,
194                relation: relation.into(),
195            },
196            Box::new(value),
197        );
198    }
199
200    pub fn relation_list_count(&self) -> usize {
201        self.relation_lists.len()
202    }
203
204    fn freeze(self) -> FrozenEntityGraph {
205        FrozenEntityGraph {
206            tables: self.tables,
207            relation_lists: self.relation_lists,
208        }
209    }
210}
211
212impl std::fmt::Debug for EntityGraphBuilder {
213    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        formatter
215            .debug_struct("EntityGraphBuilder")
216            .field("entity_types", &self.tables.len())
217            .field("entities", &self.entity_count())
218            .field("relation_lists", &self.relation_list_count())
219            .finish()
220    }
221}
222
223struct FrozenEntityGraph {
224    tables: HashMap<TypeId, EntityTable>,
225    relation_lists: HashMap<RelationListKey, Box<dyn Any + Send + Sync>>,
226}
227
228impl std::fmt::Debug for FrozenEntityGraph {
229    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        formatter
231            .debug_struct("FrozenEntityGraph")
232            .field("entity_types", &self.tables.len())
233            .field(
234                "entities",
235                &self.tables.values().map(HashMap::len).sum::<usize>(),
236            )
237            .field("relation_lists", &self.relation_lists.len())
238            .finish()
239    }
240}
241
242#[derive(Debug, Clone, Default, PartialEq)]
243pub struct EntityChangeSet {
244    changes: BTreeMap<EntityKey, MutationValues>,
245}
246
247#[derive(Debug, Clone, Default)]
248struct OriginalVersions {
249    first: Option<(EntityKey, i64)>,
250    overflow: BTreeMap<EntityKey, i64>,
251}
252
253impl OriginalVersions {
254    fn clear(&mut self) {
255        self.first = None;
256        self.overflow.clear();
257    }
258
259    fn get(&self, key: &EntityKey) -> Option<i64> {
260        self.first
261            .as_ref()
262            .and_then(|(first_key, version)| (first_key == key).then_some(*version))
263            .or_else(|| self.overflow.get(key).copied())
264    }
265
266    fn insert(&mut self, key: EntityKey, version: i64) {
267        match &mut self.first {
268            None => self.first = Some((key, version)),
269            Some((first_key, first_version)) if first_key == &key => *first_version = version,
270            Some(_) => {
271                self.overflow.insert(key, version);
272            }
273        }
274    }
275
276    fn merge_from(&mut self, source: &Self) {
277        if let Some((key, version)) = &source.first {
278            self.insert(key.clone(), *version);
279        }
280        for (key, version) in &source.overflow {
281            self.insert(key.clone(), *version);
282        }
283    }
284}
285
286impl EntityChangeSet {
287    pub fn is_empty(&self) -> bool {
288        self.changes.is_empty()
289    }
290
291    pub fn set(&mut self, key: EntityKey, field: impl Into<String>, value: Value) {
292        self.changes
293            .entry(key)
294            .or_default()
295            .insert(field.into(), value);
296    }
297
298    pub fn get(&self, key: &EntityKey, field: &str) -> Option<&Value> {
299        self.changes.get(key).and_then(|changes| changes.get(field))
300    }
301
302    pub fn changes(&self) -> &BTreeMap<EntityKey, MutationValues> {
303        &self.changes
304    }
305
306    /// Remove all pending changes for a specific entity key.
307    pub fn clear_entity(&mut self, key: &EntityKey) {
308        self.changes.remove(key);
309    }
310
311    /// Get the set of field names that have been modified for a given entity key.
312    pub fn field_names(&self, key: &EntityKey) -> BTreeSet<String> {
313        self.changes
314            .get(key)
315            .map(|record| record.keys().cloned().collect())
316            .unwrap_or_default()
317    }
318}
319
320#[derive(Debug, Clone, Default, PartialEq)]
321pub struct ChangeSetStack {
322    stack: Vec<EntityChangeSet>,
323}
324
325impl ChangeSetStack {
326    pub fn current_mut(&mut self) -> &mut EntityChangeSet {
327        if self.stack.is_empty() {
328            self.stack.push(EntityChangeSet::default());
329        }
330        self.stack.last_mut().expect("change set stack has current")
331    }
332
333    pub fn current(&self) -> Option<&EntityChangeSet> {
334        self.stack.last()
335    }
336
337    pub fn push(&mut self) {
338        self.stack.push(EntityChangeSet::default());
339    }
340
341    pub fn pop(&mut self) -> Option<EntityChangeSet> {
342        self.stack.pop()
343    }
344
345    pub fn get(&self, key: &EntityKey, field: &str) -> Option<Value> {
346        self.stack
347            .iter()
348            .rev()
349            .find_map(|change_set| change_set.get(key, field).cloned())
350    }
351
352    pub fn set(&mut self, key: EntityKey, field: impl Into<String>, value: Value) {
353        self.current_mut().set(key, field, value);
354    }
355
356    pub fn clear_current(&mut self) {
357        if let Some(current) = self.stack.last_mut() {
358            *current = EntityChangeSet::default();
359        }
360    }
361
362    /// Remove all pending changes for a specific entity key across all stack levels.
363    pub fn clear_entity(&mut self, key: &EntityKey) {
364        for change_set in &mut self.stack {
365            change_set.clear_entity(key);
366        }
367    }
368
369    /// Get the union of all changed field names for a given entity key across all stack levels.
370    /// This is the Rust equivalent of Java's `entity.getUpdatedProperties()`.
371    pub fn changed_field_names(&self, key: &EntityKey) -> BTreeSet<String> {
372        let mut fields = BTreeSet::new();
373        for change_set in &self.stack {
374            fields.extend(change_set.field_names(key));
375        }
376        fields
377    }
378}
379
380#[derive(Debug, Clone, Default)]
381struct EntityMutationLedger {
382    change_sets: ChangeSetStack,
383    /// Annotation comment for observability during graph save.
384    comment: Option<String>,
385    /// Entity keys that have been marked for deletion.
386    /// When the entity is saved, the graph save pipeline will treat these as Remove operations.
387    deleted_keys: std::collections::BTreeSet<EntityKey>,
388    /// Entity keys that have been marked as newly inserted.
389    new_keys: std::collections::BTreeSet<EntityKey>,
390    /// The original loaded snapshot, used to avoid redundant fetching during save.
391    original_snapshot: Option<OriginalSnapshot>,
392    /// Trace chains associated with each entity key.
393    trace_chains: std::collections::BTreeMap<EntityKey, Vec<teaql_core::TraceNode>>,
394    /// Original versions of entities to perform optimistic concurrency control.
395    original_versions: OriginalVersions,
396    /// Indicates if this entity root is entirely new.
397    is_new: bool,
398}
399
400#[derive(Debug)]
401pub struct EntityRuntimeState {
402    // The OnceLock itself is shared so entities composed before the first mutation
403    // still materialize exactly one graph-owned ledger.
404    inner: Arc<OnceLock<Arc<Mutex<EntityMutationLedger>>>>,
405    graph: EntityGraphReference,
406    loaded_snapshot: Option<teaql_core::CompactRow>,
407}
408
409#[derive(Debug)]
410enum EntityGraphReference {
411    Strong(Arc<OnceLock<FrozenEntityGraph>>),
412    Weak(Weak<OnceLock<FrozenEntityGraph>>),
413}
414
415impl EntityGraphReference {
416    fn preserve(&self) -> Self {
417        match self {
418            Self::Strong(graph) => Self::Strong(graph.clone()),
419            Self::Weak(graph) => Self::Weak(graph.clone()),
420        }
421    }
422
423    fn promote(&self) -> Self {
424        match self {
425            Self::Strong(graph) => Self::Strong(graph.clone()),
426            Self::Weak(graph) => graph
427                .upgrade()
428                .map(Self::Strong)
429                .unwrap_or_else(|| Self::Strong(Arc::default())),
430        }
431    }
432
433    fn weak(&self) -> Self {
434        match self {
435            Self::Strong(graph) => Self::Weak(Arc::downgrade(graph)),
436            Self::Weak(graph) => Self::Weak(graph.clone()),
437        }
438    }
439
440    fn pointer(&self) -> *const OnceLock<FrozenEntityGraph> {
441        match self {
442            Self::Strong(graph) => Arc::as_ptr(graph),
443            Self::Weak(graph) => graph.as_ptr(),
444        }
445    }
446
447    fn strong(&self) -> Option<&Arc<OnceLock<FrozenEntityGraph>>> {
448        match self {
449            Self::Strong(graph) => Some(graph),
450            Self::Weak(_) => None,
451        }
452    }
453
454    fn frozen(&self) -> Option<&FrozenEntityGraph> {
455        match self {
456            Self::Strong(graph) => graph.get(),
457            Self::Weak(graph) => {
458                let owner = graph.upgrade()?;
459                let frozen = owner.get()? as *const FrozenEntityGraph;
460                // SAFETY: weak graph references are only installed into entities owned by the
461                // same frozen graph. Such an entity can only be borrowed while an owning root
462                // keeps the graph alive. Cloning EntityRuntimeState promotes the weak reference to a
463                // strong owner, so an entity moved out through safe code also anchors the graph.
464                Some(unsafe { &*frozen })
465            }
466        }
467    }
468}
469
470impl Default for EntityRuntimeState {
471    fn default() -> Self {
472        Self {
473            inner: Arc::default(),
474            graph: EntityGraphReference::Strong(Arc::default()),
475            loaded_snapshot: None,
476        }
477    }
478}
479
480impl Clone for EntityRuntimeState {
481    fn clone(&self) -> Self {
482        Self {
483            inner: self.inner.clone(),
484            graph: self.graph.promote(),
485            loaded_snapshot: self.loaded_snapshot.clone(),
486        }
487    }
488}
489
490impl std::panic::UnwindSafe for EntityRuntimeState {}
491impl std::panic::RefUnwindSafe for EntityRuntimeState {}
492
493#[derive(Debug, Clone)]
494enum OriginalSnapshot {
495    Materialized(EntitySnapshot),
496    Compact(teaql_core::CompactRow),
497}
498
499impl PartialEq for EntityRuntimeState {
500    fn eq(&self, other: &Self) -> bool {
501        if Arc::ptr_eq(&self.inner, &other.inner) {
502            return true;
503        }
504        match (self.inner.get(), other.inner.get()) {
505            (Some(left), Some(right)) => Arc::ptr_eq(left, right),
506            (None, None) => false,
507            _ => false,
508        }
509    }
510}
511
512impl EntityRuntimeState {
513    #[cfg(test)]
514    fn has_mutation_context(&self) -> bool {
515        self.inner.get().is_some()
516    }
517
518    fn context(&self) -> &Arc<Mutex<EntityMutationLedger>> {
519        self.inner
520            .get_or_init(|| Arc::new(Mutex::new(EntityMutationLedger::default())))
521    }
522
523    fn read_context<R>(&self, default: R, read: impl FnOnce(&EntityMutationLedger) -> R) -> R {
524        let Some(context) = self.inner.get() else {
525            return default;
526        };
527        let context = context.lock().unwrap_or_else(|error| error.into_inner());
528        read(&context)
529    }
530
531    fn write_context<R>(&self, write: impl FnOnce(&mut EntityMutationLedger) -> R) -> R {
532        let mut context = self
533            .context()
534            .lock()
535            .unwrap_or_else(|error| error.into_inner());
536        write(&mut context)
537    }
538
539    pub fn fresh_with_shared_graph(source: &EntityRuntimeState) -> Self {
540        Self {
541            inner: Arc::default(),
542            graph: source.graph.preserve(),
543            loaded_snapshot: None,
544        }
545    }
546
547    /// Create a root view for an entity stored inside the graph itself. The weak view prevents
548    /// the graph from strongly owning an entity that strongly owns the graph in return.
549    pub(crate) fn fresh_with_weak_graph(source: &EntityRuntimeState) -> Self {
550        Self {
551            inner: Arc::default(),
552            graph: source.graph.weak(),
553            loaded_snapshot: None,
554        }
555    }
556
557    /// Make this root resolve entities from the same flat graph as `source`.
558    /// Existing snapshots and mutation ledger state remain owned by this root.
559    pub fn with_shared_graph(&self, source: &EntityRuntimeState) -> Self {
560        Self {
561            inner: self.inner.clone(),
562            graph: source.graph.preserve(),
563            loaded_snapshot: self.loaded_snapshot.clone(),
564        }
565    }
566
567    /// Adopt the pending mutation intent owned by `source` into this graph.
568    ///
569    /// Explicit graph composition must not lose mutations recorded before the
570    /// child was attached. The receiving graph remains the save boundary and
571    /// the source ledger is left intact so a failed composition is retryable.
572    #[doc(hidden)]
573    pub fn adopt_mutations_from(&self, source: &EntityRuntimeState) {
574        let Some(source_context) = source.inner.get() else {
575            return;
576        };
577        if self
578            .inner
579            .get()
580            .is_some_and(|target_context| Arc::ptr_eq(target_context, source_context))
581        {
582            return;
583        }
584        let snapshot = source_context
585            .lock()
586            .unwrap_or_else(|error| error.into_inner())
587            .clone();
588        self.write_context(|target| {
589            for change_set in snapshot.change_sets.stack {
590                for (key, values) in change_set.changes {
591                    for (field, value) in values {
592                        target.change_sets.set(key.clone(), field, value);
593                    }
594                }
595            }
596            target.deleted_keys.extend(snapshot.deleted_keys);
597            target.new_keys.extend(snapshot.new_keys);
598            target
599                .original_versions
600                .merge_from(&snapshot.original_versions);
601            for (key, traces) in snapshot.trace_chains {
602                target.trace_chains.entry(key).or_default().extend(traces);
603            }
604            if target.original_snapshot.is_none() {
605                target.original_snapshot = snapshot.original_snapshot;
606            }
607            if target.comment.is_none() {
608                target.comment = snapshot.comment;
609            }
610            target.is_new |= snapshot.is_new;
611        });
612    }
613
614    /// Publish a completely assembled graph. It becomes immutable after this call.
615    pub fn freeze_graph(&self, builder: EntityGraphBuilder) -> Result<(), EntityGraphBuilder> {
616        let Some(graph) = self.graph.strong() else {
617            return Err(builder);
618        };
619        graph
620            .set(builder.freeze())
621            .map_err(|graph| EntityGraphBuilder {
622                tables: graph.tables,
623                relation_lists: graph.relation_lists,
624            })
625    }
626
627    /// Resolve an entity by type and ID without locking or reference cloning.
628    pub fn resolve_entity<T>(&self, id: u64) -> Option<&T>
629    where
630        T: Any + Send + Sync,
631    {
632        self.graph
633            .frozen()?
634            .tables
635            .get(&TypeId::of::<T>())?
636            .get(&id)?
637            .downcast_ref::<T>()
638    }
639
640    pub fn resolve_relation_list<T>(
641        &self,
642        owner_entity: &str,
643        owner_id: u64,
644        relation: &str,
645    ) -> Option<&SmartList<T>>
646    where
647        T: Any + Send + Sync,
648    {
649        self.graph
650            .frozen()?
651            .relation_lists
652            .get(&RelationListKey {
653                owner_entity: crate::canonical_id_space_entity(owner_entity),
654                owner_id,
655                relation: relation.to_owned(),
656            })?
657            .downcast_ref::<SmartList<T>>()
658    }
659
660    /// Resolve a to-many relation without performing an implicit database read.
661    pub fn relation_list<T>(
662        &self,
663        owner_entity: &str,
664        owner_id: u64,
665        relation: &str,
666    ) -> RelationHandle<'_, SmartList<T>>
667    where
668        T: Any + Send + Sync,
669    {
670        let Some(graph) = self.graph.frozen() else {
671            return RelationHandle::new(LoadedRelation::NotLoaded, None);
672        };
673        let key = RelationListKey {
674            owner_entity: crate::canonical_id_space_entity(owner_entity),
675            owner_id,
676            relation: relation.to_owned(),
677        };
678        let Some(stored) = graph.relation_lists.get(&key) else {
679            return RelationHandle::new(LoadedRelation::NotLoaded, None);
680        };
681        let list = stored.downcast_ref::<SmartList<T>>().unwrap_or_else(|| {
682            panic!(
683                "relation view type mismatch: owner={} id={} relation={}",
684                owner_entity, owner_id, relation
685            )
686        });
687        if list.is_empty() {
688            RelationHandle::new(LoadedRelation::Empty, Some(list))
689        } else {
690            RelationHandle::new(LoadedRelation::Loaded, Some(list))
691        }
692    }
693
694    pub fn resolve_relation_option<T>(
695        &self,
696        owner_entity: &str,
697        owner_id: u64,
698        relation: &str,
699    ) -> Option<&Option<T>>
700    where
701        T: Any + Send + Sync,
702    {
703        self.graph
704            .frozen()?
705            .relation_lists
706            .get(&RelationListKey {
707                owner_entity: crate::canonical_id_space_entity(owner_entity),
708                owner_id,
709                relation: relation.to_owned(),
710            })?
711            .downcast_ref::<Option<T>>()
712    }
713
714    /// Resolve a to-one relation without performing an implicit database read.
715    pub fn relation_option<T>(
716        &self,
717        owner_entity: &str,
718        owner_id: u64,
719        relation: &str,
720    ) -> RelationHandle<'_, T>
721    where
722        T: Any + Send + Sync,
723    {
724        let Some(graph) = self.graph.frozen() else {
725            return RelationHandle::new(LoadedRelation::NotLoaded, None);
726        };
727        let key = RelationListKey {
728            owner_entity: crate::canonical_id_space_entity(owner_entity),
729            owner_id,
730            relation: relation.to_owned(),
731        };
732        let Some(stored) = graph.relation_lists.get(&key) else {
733            return RelationHandle::new(LoadedRelation::NotLoaded, None);
734        };
735        let value = stored.downcast_ref::<Option<T>>().unwrap_or_else(|| {
736            panic!(
737                "relation view type mismatch: owner={} id={} relation={}",
738                owner_entity, owner_id, relation
739            )
740        });
741        match value {
742            Some(value) => RelationHandle::new(LoadedRelation::Loaded, Some(value)),
743            None => RelationHandle::new(LoadedRelation::Empty, None),
744        }
745    }
746
747    pub fn has_relation_view(&self, owner_entity: &str, owner_id: u64, relation: &str) -> bool {
748        self.graph.frozen().is_some_and(|graph| {
749            graph.relation_lists.contains_key(&RelationListKey {
750                owner_entity: crate::canonical_id_space_entity(owner_entity),
751                owner_id,
752                relation: relation.to_owned(),
753            })
754        })
755    }
756
757    pub fn push_change_set(&self) {
758        self.write_context(|context| context.change_sets.push());
759    }
760
761    pub fn pop_change_set(&self) -> Option<EntityChangeSet> {
762        self.inner.get()?;
763        self.write_context(|context| context.change_sets.pop())
764    }
765
766    pub fn clear_current_change_set(&self) {
767        if self.inner.get().is_some() {
768            self.write_context(|context| context.change_sets.clear_current());
769        }
770    }
771
772    /// Clear all state consumed by a successfully committed ledger save.
773    /// Failed saves must not call this method so their pending intent remains retryable.
774    pub fn clear_committed(&self) {
775        if self.inner.get().is_some() {
776            self.write_context(|context| {
777                context.change_sets = ChangeSetStack::default();
778                context.deleted_keys.clear();
779                context.new_keys.clear();
780                context.original_versions.clear();
781                context.trace_chains.clear();
782                context.original_snapshot = None;
783                context.comment = None;
784                context.is_new = false;
785            });
786        }
787    }
788
789    pub fn set(&self, key: EntityKey, field: impl Into<String>, value: impl Into<Value>) {
790        self.write_context(|context| context.change_sets.set(key, field, value.into()));
791    }
792
793    pub fn get(&self, key: &EntityKey, field: &str) -> Option<Value> {
794        self.read_context(None, |context| context.change_sets.get(key, field))
795    }
796
797    pub fn current_change_set(&self) -> EntityChangeSet {
798        self.read_context(EntityChangeSet::default(), |context| {
799            context.change_sets.current().cloned().unwrap_or_default()
800        })
801    }
802
803    /// Set an annotation comment on this entity root.
804    /// The comment propagates through the graph save process for observability.
805    pub fn set_comment(&self, comment: impl Into<String>) {
806        self.write_context(|context| context.comment = Some(comment.into()));
807    }
808
809    /// Get the annotation comment, if any.
810    pub fn get_comment(&self) -> Option<String> {
811        self.read_context(None, |context| context.comment.clone())
812    }
813
814    /// Mark this entity root as a newly created entity in memory.
815    pub fn mark_as_new(&self, key: EntityKey) {
816        self.write_context(|context| {
817            context.new_keys.insert(key);
818        });
819    }
820
821    /// Check if this entity root is marked as newly created.
822    pub fn is_new(&self, key: &EntityKey) -> bool {
823        self.read_context(false, |context| context.new_keys.contains(key))
824    }
825
826    /// Store an original loaded entity snapshot.
827    pub fn set_original_snapshot(&self, snapshot: EntitySnapshot) {
828        self.write_context(|context| {
829            context.original_snapshot = Some(OriginalSnapshot::Materialized(snapshot));
830        });
831    }
832
833    /// Store a shared-schema snapshot without allocating a mutation ledger.
834    pub fn set_original_compact_row(&mut self, row: teaql_core::CompactRow) {
835        self.loaded_snapshot = Some(row);
836    }
837
838    /// Retrieve the original loaded entity snapshot.
839    pub fn original_snapshot(&self) -> Option<EntitySnapshot> {
840        if let Some(row) = &self.loaded_snapshot {
841            return Some(EntitySnapshot::from(row.clone().into_map()));
842        }
843        self.read_context(None, |context| {
844            context
845                .original_snapshot
846                .as_ref()
847                .map(|snapshot| match snapshot {
848                    OriginalSnapshot::Materialized(snapshot) => snapshot.clone(),
849                    OriginalSnapshot::Compact(row) => EntitySnapshot::from(row.clone().into_map()),
850                })
851        })
852    }
853
854    /// Mark an entity as deleted. The next `save()` call will treat this entity
855    /// as a Remove operation in the graph save pipeline.
856    /// Any pending field changes for this entity are cleared — they are irrelevant
857    /// when the entity is being deleted.
858    pub fn mark_as_delete(&self, key: EntityKey) {
859        self.write_context(|context| {
860            context.change_sets.clear_entity(&key);
861            context.deleted_keys.insert(key);
862        });
863    }
864
865    /// Check whether an entity has been marked for deletion.
866    pub fn is_marked_as_delete(&self, key: &EntityKey) -> bool {
867        self.read_context(false, |context| context.deleted_keys.contains(key))
868    }
869
870    /// Get the set of field names that have been modified for the given entity key.
871    /// This is the Rust equivalent of Java's `entity.getUpdatedProperties()`.
872    pub fn changed_field_names(&self, key: &EntityKey) -> BTreeSet<String> {
873        self.read_context(BTreeSet::new(), |context| {
874            context.change_sets.changed_field_names(key)
875        })
876    }
877    pub fn deleted_keys(&self) -> std::collections::BTreeSet<EntityKey> {
878        self.read_context(BTreeSet::new(), |context| context.deleted_keys.clone())
879    }
880
881    pub fn new_keys(&self) -> std::collections::BTreeSet<EntityKey> {
882        self.read_context(BTreeSet::new(), |context| context.new_keys.clone())
883    }
884
885    pub fn get_original_version(&self, key: &EntityKey) -> Option<i64> {
886        self.read_context(None, |context| context.original_versions.get(key))
887            .or_else(|| {
888                self.loaded_snapshot
889                    .as_ref()?
890                    .get("id")?
891                    .try_u64()
892                    .filter(|id| Some(*id) == key.id.try_u64())?;
893                self.loaded_snapshot.as_ref()?.get("version")?.try_i64()
894            })
895    }
896
897    pub fn get_trace_chain(&self, key: &EntityKey) -> Vec<teaql_core::TraceNode> {
898        self.read_context(Vec::new(), |context| {
899            context.trace_chains.get(key).cloned().unwrap_or_default()
900        })
901    }
902
903    pub fn set_original_version(&self, key: EntityKey, version: i64) {
904        self.write_context(|context| context.original_versions.insert(key, version));
905    }
906}
907
908pub trait LedgerEntity: teaql_core::Entity {
909    fn entity_runtime_state(&self) -> Option<EntityRuntimeState>;
910}
911
912#[cfg(test)]
913mod lazy_root_tests {
914    use super::*;
915
916    #[derive(Clone)]
917    struct GraphChild {
918        root: EntityRuntimeState,
919    }
920
921    #[test]
922    fn loaded_snapshot_does_not_allocate_ledger_until_mutation() {
923        let mut root = EntityRuntimeState::default();
924        root.set_original_compact_row(teaql_core::CompactRow::new(
925            Arc::from(["id".to_owned(), "version".to_owned()]),
926            vec![Value::U64(7), Value::I64(3)],
927        ));
928        let key = EntityKey::new_static("Example", 7_u64);
929
930        assert!(!root.has_mutation_context());
931        assert_eq!(root.get(&key, "name"), None);
932        assert_eq!(root.get_original_version(&key), Some(3));
933        assert!(!root.has_mutation_context());
934
935        root.set(key, "name", Value::Text("updated".to_owned()));
936        assert!(root.has_mutation_context());
937    }
938
939    #[test]
940    fn clone_before_first_mutation_materializes_one_shared_ledger() {
941        let root = EntityRuntimeState::default();
942        let child = root.clone();
943        let child_key = EntityKey::new_static("Child", 2_u64);
944
945        child.set(child_key.clone(), "name", Value::Text("updated".to_owned()));
946
947        assert_eq!(
948            root.get(&child_key, "name"),
949            Some(Value::Text("updated".to_owned()))
950        );
951        assert!(root.has_mutation_context());
952    }
953
954    #[test]
955    fn explicit_graph_composition_adopts_existing_child_mutations() {
956        let parent = EntityRuntimeState::default();
957        let child = EntityRuntimeState::default();
958        let child_key = EntityKey::new_static("Child", 17_u64);
959
960        child.mark_as_new(child_key.clone());
961        child.set(child_key.clone(), "display_name", "before attach");
962        child.set_original_version(child_key.clone(), 3);
963
964        parent.adopt_mutations_from(&child);
965
966        assert!(parent.is_new(&child_key));
967        assert_eq!(
968            parent.get(&child_key, "display_name"),
969            Some(Value::Text("before attach".to_owned()))
970        );
971        assert_eq!(parent.get_original_version(&child_key), Some(3));
972    }
973
974    #[test]
975    fn graph_owned_entities_do_not_keep_the_graph_alive() {
976        let root = EntityRuntimeState::default();
977        let graph_owner = match &root.graph {
978            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
979            EntityGraphReference::Weak(_) => unreachable!(),
980        };
981        let mut builder = EntityGraphBuilder::default();
982        builder.install_relation_list(
983            "Owner",
984            1,
985            "children",
986            SmartList::from(vec![GraphChild {
987                root: EntityRuntimeState::fresh_with_weak_graph(&root),
988            }]),
989        );
990        root.freeze_graph(builder).unwrap();
991
992        drop(root);
993        assert!(graph_owner.upgrade().is_none());
994    }
995
996    #[test]
997    fn cloning_a_graph_owned_entity_promotes_its_graph_anchor() {
998        let root = EntityRuntimeState::default();
999        let graph_owner = match &root.graph {
1000            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
1001            EntityGraphReference::Weak(_) => unreachable!(),
1002        };
1003        let mut builder = EntityGraphBuilder::default();
1004        builder.install_relation_list(
1005            "Owner",
1006            1,
1007            "children",
1008            SmartList::from(vec![GraphChild {
1009                root: EntityRuntimeState::fresh_with_weak_graph(&root),
1010            }]),
1011        );
1012        root.freeze_graph(builder).unwrap();
1013        let detached = root
1014            .resolve_relation_list::<GraphChild>("Owner", 1, "children")
1015            .unwrap()[0]
1016            .clone();
1017
1018        drop(root);
1019        assert!(graph_owner.upgrade().is_some());
1020        assert!(detached.root.graph.frozen().is_some());
1021        drop(detached);
1022        assert!(graph_owner.upgrade().is_none());
1023    }
1024
1025    #[test]
1026    fn relation_handles_distinguish_loaded_empty_and_not_loaded() {
1027        let root = EntityRuntimeState::default();
1028        let mut builder = EntityGraphBuilder::default();
1029        builder.install_relation_list("Owner", 1, "loaded", SmartList::from(vec![7_u64]));
1030        builder.install_relation_list::<u64>("Owner", 1, "empty", SmartList::empty());
1031        builder.install_relation_option("Owner", 1, "present", Some(9_u64));
1032        builder.install_relation_option::<u64>("Owner", 1, "null", None);
1033        root.freeze_graph(builder).unwrap();
1034
1035        let loaded = root.relation_list::<u64>("Owner", 1, "loaded");
1036        assert_eq!(loaded.state(), LoadedRelation::Loaded);
1037        assert_eq!(loaded.value().map(|list| list.as_slice()), Some(&[7][..]));
1038
1039        let empty = root.relation_list::<u64>("Owner", 1, "empty");
1040        assert_eq!(empty.state(), LoadedRelation::Empty);
1041        assert!(empty.value().is_some_and(SmartList::is_empty));
1042
1043        let missing = root.relation_list::<u64>("Owner", 1, "missing");
1044        assert_eq!(missing.state(), LoadedRelation::NotLoaded);
1045        assert!(missing.value().is_none());
1046
1047        let present = root.relation_option::<u64>("Owner", 1, "present");
1048        assert_eq!(present.state(), LoadedRelation::Loaded);
1049        assert_eq!(present.value(), Some(&9));
1050
1051        let null = root.relation_option::<u64>("Owner", 1, "null");
1052        assert_eq!(null.state(), LoadedRelation::Empty);
1053        assert!(null.value().is_none());
1054
1055        let absent = root.relation_option::<u64>("Owner", 1, "absent");
1056        assert_eq!(absent.state(), LoadedRelation::NotLoaded);
1057        assert!(absent.value().is_none());
1058    }
1059}