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<LoadedEntitySnapshot>,
407}
408
409#[derive(Debug, Clone)]
410struct LoadedEntitySnapshot {
411    entity: Arc<str>,
412    row: teaql_core::CompactRow,
413}
414
415#[derive(Debug)]
416enum EntityGraphReference {
417    Strong(Arc<OnceLock<FrozenEntityGraph>>),
418    Weak(Weak<OnceLock<FrozenEntityGraph>>),
419}
420
421impl EntityGraphReference {
422    fn preserve(&self) -> Self {
423        match self {
424            Self::Strong(graph) => Self::Strong(graph.clone()),
425            Self::Weak(graph) => Self::Weak(graph.clone()),
426        }
427    }
428
429    fn promote(&self) -> Self {
430        match self {
431            Self::Strong(graph) => Self::Strong(graph.clone()),
432            Self::Weak(graph) => graph
433                .upgrade()
434                .map(Self::Strong)
435                .unwrap_or_else(|| Self::Strong(Arc::default())),
436        }
437    }
438
439    fn weak(&self) -> Self {
440        match self {
441            Self::Strong(graph) => Self::Weak(Arc::downgrade(graph)),
442            Self::Weak(graph) => Self::Weak(graph.clone()),
443        }
444    }
445
446    fn pointer(&self) -> *const OnceLock<FrozenEntityGraph> {
447        match self {
448            Self::Strong(graph) => Arc::as_ptr(graph),
449            Self::Weak(graph) => graph.as_ptr(),
450        }
451    }
452
453    fn strong(&self) -> Option<&Arc<OnceLock<FrozenEntityGraph>>> {
454        match self {
455            Self::Strong(graph) => Some(graph),
456            Self::Weak(_) => None,
457        }
458    }
459
460    fn frozen(&self) -> Option<&FrozenEntityGraph> {
461        match self {
462            Self::Strong(graph) => graph.get(),
463            Self::Weak(graph) => {
464                let owner = graph.upgrade()?;
465                let frozen = owner.get()? as *const FrozenEntityGraph;
466                // SAFETY: weak graph references are only installed into entities owned by the
467                // same frozen graph. Such an entity can only be borrowed while an owning root
468                // keeps the graph alive. Cloning EntityRuntimeState promotes the weak reference to a
469                // strong owner, so an entity moved out through safe code also anchors the graph.
470                Some(unsafe { &*frozen })
471            }
472        }
473    }
474}
475
476impl Default for EntityRuntimeState {
477    fn default() -> Self {
478        Self {
479            inner: Arc::default(),
480            graph: EntityGraphReference::Strong(Arc::default()),
481            loaded_snapshot: None,
482        }
483    }
484}
485
486impl Clone for EntityRuntimeState {
487    fn clone(&self) -> Self {
488        Self {
489            inner: self.inner.clone(),
490            graph: self.graph.promote(),
491            loaded_snapshot: self.loaded_snapshot.clone(),
492        }
493    }
494}
495
496impl std::panic::UnwindSafe for EntityRuntimeState {}
497impl std::panic::RefUnwindSafe for EntityRuntimeState {}
498
499#[derive(Debug, Clone)]
500enum OriginalSnapshot {
501    Materialized(EntitySnapshot),
502    Compact(teaql_core::CompactRow),
503}
504
505impl PartialEq for EntityRuntimeState {
506    fn eq(&self, other: &Self) -> bool {
507        if Arc::ptr_eq(&self.inner, &other.inner) {
508            return true;
509        }
510        match (self.inner.get(), other.inner.get()) {
511            (Some(left), Some(right)) => Arc::ptr_eq(left, right),
512            (None, None) => false,
513            _ => false,
514        }
515    }
516}
517
518impl EntityRuntimeState {
519    #[cfg(test)]
520    fn has_mutation_context(&self) -> bool {
521        self.inner.get().is_some()
522    }
523
524    fn context(&self) -> &Arc<Mutex<EntityMutationLedger>> {
525        self.inner
526            .get_or_init(|| Arc::new(Mutex::new(EntityMutationLedger::default())))
527    }
528
529    fn read_context<R>(&self, default: R, read: impl FnOnce(&EntityMutationLedger) -> R) -> R {
530        let Some(context) = self.inner.get() else {
531            return default;
532        };
533        let context = context.lock().unwrap_or_else(|error| error.into_inner());
534        read(&context)
535    }
536
537    fn write_context<R>(&self, write: impl FnOnce(&mut EntityMutationLedger) -> R) -> R {
538        let mut context = self
539            .context()
540            .lock()
541            .unwrap_or_else(|error| error.into_inner());
542        write(&mut context)
543    }
544
545    pub fn fresh_with_shared_graph(source: &EntityRuntimeState) -> Self {
546        Self {
547            inner: Arc::default(),
548            graph: source.graph.preserve(),
549            loaded_snapshot: None,
550        }
551    }
552
553    /// Create a root view for an entity stored inside the graph itself. The weak view prevents
554    /// the graph from strongly owning an entity that strongly owns the graph in return.
555    pub(crate) fn fresh_with_weak_graph(source: &EntityRuntimeState) -> Self {
556        Self {
557            inner: Arc::default(),
558            graph: source.graph.weak(),
559            loaded_snapshot: None,
560        }
561    }
562
563    /// Make this root resolve entities from the same flat graph as `source`.
564    /// Existing snapshots and mutation ledger state remain owned by this root.
565    pub fn with_shared_graph(&self, source: &EntityRuntimeState) -> Self {
566        Self {
567            inner: self.inner.clone(),
568            graph: source.graph.preserve(),
569            loaded_snapshot: self.loaded_snapshot.clone(),
570        }
571    }
572
573    /// Adopt the pending mutation intent owned by `source` into this graph.
574    ///
575    /// Explicit graph composition must not lose mutations recorded before the
576    /// child was attached. The receiving graph remains the save boundary and
577    /// the source ledger is left intact so a failed composition is retryable.
578    #[doc(hidden)]
579    pub fn adopt_mutations_from(&self, source: &EntityRuntimeState) {
580        let Some(source_context) = source.inner.get() else {
581            return;
582        };
583        if self
584            .inner
585            .get()
586            .is_some_and(|target_context| Arc::ptr_eq(target_context, source_context))
587        {
588            return;
589        }
590        let snapshot = source_context
591            .lock()
592            .unwrap_or_else(|error| error.into_inner())
593            .clone();
594        let loaded_version = source.loaded_snapshot.as_ref().and_then(|loaded| {
595            let id = loaded.row.get("id")?.clone();
596            let version = loaded.row.get("version")?.try_i64()?;
597            Some((EntityKey::new(loaded.entity.as_ref(), id), version))
598        });
599        self.write_context(|target| {
600            for change_set in snapshot.change_sets.stack {
601                for (key, values) in change_set.changes {
602                    for (field, value) in values {
603                        target.change_sets.set(key.clone(), field, value);
604                    }
605                }
606            }
607            target.deleted_keys.extend(snapshot.deleted_keys);
608            target.new_keys.extend(snapshot.new_keys);
609            target
610                .original_versions
611                .merge_from(&snapshot.original_versions);
612            if let Some((key, version)) = loaded_version {
613                target.original_versions.insert(key, version);
614            }
615            for (key, traces) in snapshot.trace_chains {
616                target.trace_chains.entry(key).or_default().extend(traces);
617            }
618            if target.original_snapshot.is_none() {
619                target.original_snapshot = snapshot.original_snapshot;
620            }
621            if target.comment.is_none() {
622                target.comment = snapshot.comment;
623            }
624            target.is_new |= snapshot.is_new;
625        });
626    }
627
628    /// Publish a completely assembled graph. It becomes immutable after this call.
629    pub fn freeze_graph(&self, builder: EntityGraphBuilder) -> Result<(), EntityGraphBuilder> {
630        let Some(graph) = self.graph.strong() else {
631            return Err(builder);
632        };
633        graph
634            .set(builder.freeze())
635            .map_err(|graph| EntityGraphBuilder {
636                tables: graph.tables,
637                relation_lists: graph.relation_lists,
638            })
639    }
640
641    /// Resolve an entity by type and ID without locking or reference cloning.
642    pub fn resolve_entity<T>(&self, id: u64) -> Option<&T>
643    where
644        T: Any + Send + Sync,
645    {
646        self.graph
647            .frozen()?
648            .tables
649            .get(&TypeId::of::<T>())?
650            .get(&id)?
651            .downcast_ref::<T>()
652    }
653
654    pub fn resolve_relation_list<T>(
655        &self,
656        owner_entity: &str,
657        owner_id: u64,
658        relation: &str,
659    ) -> Option<&SmartList<T>>
660    where
661        T: Any + Send + Sync,
662    {
663        self.graph
664            .frozen()?
665            .relation_lists
666            .get(&RelationListKey {
667                owner_entity: crate::canonical_id_space_entity(owner_entity),
668                owner_id,
669                relation: relation.to_owned(),
670            })?
671            .downcast_ref::<SmartList<T>>()
672    }
673
674    /// Resolve a to-many relation without performing an implicit database read.
675    pub fn relation_list<T>(
676        &self,
677        owner_entity: &str,
678        owner_id: u64,
679        relation: &str,
680    ) -> RelationHandle<'_, SmartList<T>>
681    where
682        T: Any + Send + Sync,
683    {
684        let Some(graph) = self.graph.frozen() else {
685            return RelationHandle::new(LoadedRelation::NotLoaded, None);
686        };
687        let key = RelationListKey {
688            owner_entity: crate::canonical_id_space_entity(owner_entity),
689            owner_id,
690            relation: relation.to_owned(),
691        };
692        let Some(stored) = graph.relation_lists.get(&key) else {
693            return RelationHandle::new(LoadedRelation::NotLoaded, None);
694        };
695        let list = stored.downcast_ref::<SmartList<T>>().unwrap_or_else(|| {
696            panic!(
697                "relation view type mismatch: owner={} id={} relation={}",
698                owner_entity, owner_id, relation
699            )
700        });
701        if list.is_empty() {
702            RelationHandle::new(LoadedRelation::Empty, Some(list))
703        } else {
704            RelationHandle::new(LoadedRelation::Loaded, Some(list))
705        }
706    }
707
708    pub fn resolve_relation_option<T>(
709        &self,
710        owner_entity: &str,
711        owner_id: u64,
712        relation: &str,
713    ) -> Option<&Option<T>>
714    where
715        T: Any + Send + Sync,
716    {
717        self.graph
718            .frozen()?
719            .relation_lists
720            .get(&RelationListKey {
721                owner_entity: crate::canonical_id_space_entity(owner_entity),
722                owner_id,
723                relation: relation.to_owned(),
724            })?
725            .downcast_ref::<Option<T>>()
726    }
727
728    /// Resolve a to-one relation without performing an implicit database read.
729    pub fn relation_option<T>(
730        &self,
731        owner_entity: &str,
732        owner_id: u64,
733        relation: &str,
734    ) -> RelationHandle<'_, T>
735    where
736        T: Any + Send + Sync,
737    {
738        let Some(graph) = self.graph.frozen() else {
739            return RelationHandle::new(LoadedRelation::NotLoaded, None);
740        };
741        let key = RelationListKey {
742            owner_entity: crate::canonical_id_space_entity(owner_entity),
743            owner_id,
744            relation: relation.to_owned(),
745        };
746        let Some(stored) = graph.relation_lists.get(&key) else {
747            return RelationHandle::new(LoadedRelation::NotLoaded, None);
748        };
749        let value = stored.downcast_ref::<Option<T>>().unwrap_or_else(|| {
750            panic!(
751                "relation view type mismatch: owner={} id={} relation={}",
752                owner_entity, owner_id, relation
753            )
754        });
755        match value {
756            Some(value) => RelationHandle::new(LoadedRelation::Loaded, Some(value)),
757            None => RelationHandle::new(LoadedRelation::Empty, None),
758        }
759    }
760
761    pub fn has_relation_view(&self, owner_entity: &str, owner_id: u64, relation: &str) -> bool {
762        self.graph.frozen().is_some_and(|graph| {
763            graph.relation_lists.contains_key(&RelationListKey {
764                owner_entity: crate::canonical_id_space_entity(owner_entity),
765                owner_id,
766                relation: relation.to_owned(),
767            })
768        })
769    }
770
771    pub fn push_change_set(&self) {
772        self.write_context(|context| context.change_sets.push());
773    }
774
775    pub fn pop_change_set(&self) -> Option<EntityChangeSet> {
776        self.inner.get()?;
777        self.write_context(|context| context.change_sets.pop())
778    }
779
780    pub fn clear_current_change_set(&self) {
781        if self.inner.get().is_some() {
782            self.write_context(|context| context.change_sets.clear_current());
783        }
784    }
785
786    /// Clear all state consumed by a successfully committed ledger save.
787    /// Failed saves must not call this method so their pending intent remains retryable.
788    pub fn clear_committed(&self) {
789        if self.inner.get().is_some() {
790            self.write_context(|context| {
791                context.change_sets = ChangeSetStack::default();
792                context.deleted_keys.clear();
793                context.new_keys.clear();
794                context.original_versions.clear();
795                context.trace_chains.clear();
796                context.original_snapshot = None;
797                context.comment = None;
798                context.is_new = false;
799            });
800        }
801    }
802
803    pub fn set(&self, key: EntityKey, field: impl Into<String>, value: impl Into<Value>) {
804        self.write_context(|context| context.change_sets.set(key, field, value.into()));
805    }
806
807    pub fn get(&self, key: &EntityKey, field: &str) -> Option<Value> {
808        self.read_context(None, |context| context.change_sets.get(key, field))
809    }
810
811    pub fn current_change_set(&self) -> EntityChangeSet {
812        self.read_context(EntityChangeSet::default(), |context| {
813            context.change_sets.current().cloned().unwrap_or_default()
814        })
815    }
816
817    /// Set an annotation comment on this entity root.
818    /// The comment propagates through the graph save process for observability.
819    pub fn set_comment(&self, comment: impl Into<String>) {
820        self.write_context(|context| context.comment = Some(comment.into()));
821    }
822
823    /// Get the annotation comment, if any.
824    pub fn get_comment(&self) -> Option<String> {
825        self.read_context(None, |context| context.comment.clone())
826    }
827
828    /// Mark this entity root as a newly created entity in memory.
829    pub fn mark_as_new(&self, key: EntityKey) {
830        self.write_context(|context| {
831            context.new_keys.insert(key);
832        });
833    }
834
835    /// Check if this entity root is marked as newly created.
836    pub fn is_new(&self, key: &EntityKey) -> bool {
837        self.read_context(false, |context| context.new_keys.contains(key))
838    }
839
840    /// Store an original loaded entity snapshot.
841    pub fn set_original_snapshot(&self, snapshot: EntitySnapshot) {
842        self.write_context(|context| {
843            context.original_snapshot = Some(OriginalSnapshot::Materialized(snapshot));
844        });
845    }
846
847    /// Store a shared-schema snapshot without allocating a mutation ledger.
848    pub fn set_original_compact_row(
849        &mut self,
850        entity: impl Into<Arc<str>>,
851        row: teaql_core::CompactRow,
852    ) {
853        self.loaded_snapshot = Some(LoadedEntitySnapshot {
854            entity: entity.into(),
855            row,
856        });
857    }
858
859    /// Retrieve the original loaded entity snapshot.
860    pub fn original_snapshot(&self) -> Option<EntitySnapshot> {
861        if let Some(snapshot) = &self.loaded_snapshot {
862            return Some(EntitySnapshot::from(snapshot.row.clone().into_map()));
863        }
864        self.read_context(None, |context| {
865            context
866                .original_snapshot
867                .as_ref()
868                .map(|snapshot| match snapshot {
869                    OriginalSnapshot::Materialized(snapshot) => snapshot.clone(),
870                    OriginalSnapshot::Compact(row) => EntitySnapshot::from(row.clone().into_map()),
871                })
872        })
873    }
874
875    /// Mark an entity as deleted. The next `save()` call will treat this entity
876    /// as a Remove operation in the graph save pipeline.
877    /// Any pending field changes for this entity are cleared — they are irrelevant
878    /// when the entity is being deleted.
879    pub fn mark_as_delete(&self, key: EntityKey) {
880        self.write_context(|context| {
881            context.change_sets.clear_entity(&key);
882            context.deleted_keys.insert(key);
883        });
884    }
885
886    /// Check whether an entity has been marked for deletion.
887    pub fn is_marked_as_delete(&self, key: &EntityKey) -> bool {
888        self.read_context(false, |context| context.deleted_keys.contains(key))
889    }
890
891    /// Get the set of field names that have been modified for the given entity key.
892    /// This is the Rust equivalent of Java's `entity.getUpdatedProperties()`.
893    pub fn changed_field_names(&self, key: &EntityKey) -> BTreeSet<String> {
894        self.read_context(BTreeSet::new(), |context| {
895            context.change_sets.changed_field_names(key)
896        })
897    }
898    pub fn deleted_keys(&self) -> std::collections::BTreeSet<EntityKey> {
899        self.read_context(BTreeSet::new(), |context| context.deleted_keys.clone())
900    }
901
902    pub fn new_keys(&self) -> std::collections::BTreeSet<EntityKey> {
903        self.read_context(BTreeSet::new(), |context| context.new_keys.clone())
904    }
905
906    pub fn get_original_version(&self, key: &EntityKey) -> Option<i64> {
907        self.read_context(None, |context| context.original_versions.get(key))
908            .or_else(|| {
909                let snapshot = self.loaded_snapshot.as_ref()?;
910                if snapshot.entity.as_ref() != key.entity.as_ref() {
911                    return None;
912                }
913                snapshot
914                    .row
915                    .get("id")?
916                    .try_u64()
917                    .filter(|id| Some(*id) == key.id.try_u64())?;
918                snapshot.row.get("version")?.try_i64()
919            })
920    }
921
922    pub fn get_trace_chain(&self, key: &EntityKey) -> Vec<teaql_core::TraceNode> {
923        self.read_context(Vec::new(), |context| {
924            context.trace_chains.get(key).cloned().unwrap_or_default()
925        })
926    }
927
928    pub fn set_original_version(&self, key: EntityKey, version: i64) {
929        self.write_context(|context| context.original_versions.insert(key, version));
930    }
931}
932
933pub trait LedgerEntity: teaql_core::Entity {
934    fn entity_runtime_state(&self) -> Option<EntityRuntimeState>;
935}
936
937#[cfg(test)]
938mod lazy_root_tests {
939    use super::*;
940
941    #[derive(Clone)]
942    struct GraphChild {
943        root: EntityRuntimeState,
944    }
945
946    #[test]
947    fn loaded_snapshot_does_not_allocate_ledger_until_mutation() {
948        let mut root = EntityRuntimeState::default();
949        root.set_original_compact_row(
950            "Example",
951            teaql_core::CompactRow::new(
952                Arc::from(["id".to_owned(), "version".to_owned()]),
953                vec![Value::U64(7), Value::I64(3)],
954            ),
955        );
956        let key = EntityKey::new_static("Example", 7_u64);
957
958        assert!(!root.has_mutation_context());
959        assert_eq!(root.get(&key, "name"), None);
960        assert_eq!(root.get_original_version(&key), Some(3));
961        assert!(!root.has_mutation_context());
962
963        root.set(key, "name", Value::Text("updated".to_owned()));
964        assert!(root.has_mutation_context());
965    }
966
967    #[test]
968    fn graph_composition_retains_version_for_same_id_across_entity_types() {
969        let mut parent = EntityRuntimeState::default();
970        parent.set_original_compact_row(
971            "Order",
972            teaql_core::CompactRow::new(
973                Arc::from(["id".to_owned(), "version".to_owned()]),
974                vec![Value::U64(1), Value::I64(1)],
975            ),
976        );
977        let mut execution = EntityRuntimeState::default();
978        execution.set_original_compact_row(
979            "InferenceExecution",
980            teaql_core::CompactRow::new(
981                Arc::from(["id".to_owned(), "version".to_owned()]),
982                vec![Value::U64(1), Value::I64(2)],
983            ),
984        );
985        let order_key = EntityKey::new_static("Order", 1_u64);
986        let execution_key = EntityKey::new_static("InferenceExecution", 1_u64);
987        execution.set(
988            execution_key.clone(),
989            "execution_status",
990            Value::Text("COMPLETED".to_owned()),
991        );
992
993        assert_eq!(parent.get_original_version(&execution_key), None);
994        parent.adopt_mutations_from(&execution);
995
996        assert_eq!(parent.get_original_version(&order_key), Some(1));
997        assert_eq!(parent.get_original_version(&execution_key), Some(2));
998    }
999
1000    #[test]
1001    fn clone_before_first_mutation_materializes_one_shared_ledger() {
1002        let root = EntityRuntimeState::default();
1003        let child = root.clone();
1004        let child_key = EntityKey::new_static("Child", 2_u64);
1005
1006        child.set(child_key.clone(), "name", Value::Text("updated".to_owned()));
1007
1008        assert_eq!(
1009            root.get(&child_key, "name"),
1010            Some(Value::Text("updated".to_owned()))
1011        );
1012        assert!(root.has_mutation_context());
1013    }
1014
1015    #[test]
1016    fn explicit_graph_composition_adopts_existing_child_mutations() {
1017        let parent = EntityRuntimeState::default();
1018        let child = EntityRuntimeState::default();
1019        let child_key = EntityKey::new_static("Child", 17_u64);
1020
1021        child.mark_as_new(child_key.clone());
1022        child.set(child_key.clone(), "display_name", "before attach");
1023        child.set_original_version(child_key.clone(), 3);
1024
1025        parent.adopt_mutations_from(&child);
1026
1027        assert!(parent.is_new(&child_key));
1028        assert_eq!(
1029            parent.get(&child_key, "display_name"),
1030            Some(Value::Text("before attach".to_owned()))
1031        );
1032        assert_eq!(parent.get_original_version(&child_key), Some(3));
1033    }
1034
1035    #[test]
1036    fn graph_owned_entities_do_not_keep_the_graph_alive() {
1037        let root = EntityRuntimeState::default();
1038        let graph_owner = match &root.graph {
1039            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
1040            EntityGraphReference::Weak(_) => unreachable!(),
1041        };
1042        let mut builder = EntityGraphBuilder::default();
1043        builder.install_relation_list(
1044            "Owner",
1045            1,
1046            "children",
1047            SmartList::from(vec![GraphChild {
1048                root: EntityRuntimeState::fresh_with_weak_graph(&root),
1049            }]),
1050        );
1051        root.freeze_graph(builder).unwrap();
1052
1053        drop(root);
1054        assert!(graph_owner.upgrade().is_none());
1055    }
1056
1057    #[test]
1058    fn cloning_a_graph_owned_entity_promotes_its_graph_anchor() {
1059        let root = EntityRuntimeState::default();
1060        let graph_owner = match &root.graph {
1061            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
1062            EntityGraphReference::Weak(_) => unreachable!(),
1063        };
1064        let mut builder = EntityGraphBuilder::default();
1065        builder.install_relation_list(
1066            "Owner",
1067            1,
1068            "children",
1069            SmartList::from(vec![GraphChild {
1070                root: EntityRuntimeState::fresh_with_weak_graph(&root),
1071            }]),
1072        );
1073        root.freeze_graph(builder).unwrap();
1074        let detached = root
1075            .resolve_relation_list::<GraphChild>("Owner", 1, "children")
1076            .unwrap()[0]
1077            .clone();
1078
1079        drop(root);
1080        assert!(graph_owner.upgrade().is_some());
1081        assert!(detached.root.graph.frozen().is_some());
1082        drop(detached);
1083        assert!(graph_owner.upgrade().is_none());
1084    }
1085
1086    #[test]
1087    fn relation_handles_distinguish_loaded_empty_and_not_loaded() {
1088        let root = EntityRuntimeState::default();
1089        let mut builder = EntityGraphBuilder::default();
1090        builder.install_relation_list("Owner", 1, "loaded", SmartList::from(vec![7_u64]));
1091        builder.install_relation_list::<u64>("Owner", 1, "empty", SmartList::empty());
1092        builder.install_relation_option("Owner", 1, "present", Some(9_u64));
1093        builder.install_relation_option::<u64>("Owner", 1, "null", None);
1094        root.freeze_graph(builder).unwrap();
1095
1096        let loaded = root.relation_list::<u64>("Owner", 1, "loaded");
1097        assert_eq!(loaded.state(), LoadedRelation::Loaded);
1098        assert_eq!(loaded.value().map(|list| list.as_slice()), Some(&[7][..]));
1099
1100        let empty = root.relation_list::<u64>("Owner", 1, "empty");
1101        assert_eq!(empty.state(), LoadedRelation::Empty);
1102        assert!(empty.value().is_some_and(SmartList::is_empty));
1103
1104        let missing = root.relation_list::<u64>("Owner", 1, "missing");
1105        assert_eq!(missing.state(), LoadedRelation::NotLoaded);
1106        assert!(missing.value().is_none());
1107
1108        let present = root.relation_option::<u64>("Owner", 1, "present");
1109        assert_eq!(present.state(), LoadedRelation::Loaded);
1110        assert_eq!(present.value(), Some(&9));
1111
1112        let null = root.relation_option::<u64>("Owner", 1, "null");
1113        assert_eq!(null.state(), LoadedRelation::Empty);
1114        assert!(null.value().is_none());
1115
1116        let absent = root.relation_option::<u64>("Owner", 1, "absent");
1117        assert_eq!(absent.state(), LoadedRelation::NotLoaded);
1118        assert!(absent.value().is_none());
1119    }
1120}