Skip to main content

teaql_runtime/
context.rs

1use std::any::{Any, TypeId};
2use std::collections::{BTreeMap, HashMap};
3use std::future::Future;
4
5use std::pin::Pin;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Condvar, Mutex, OnceLock};
8use std::time::{Duration, Instant, SystemTime};
9
10use teaql_core::{EntityDescriptor, Value};
11use teaql_sql::{CompiledQuery, DatabaseKind};
12
13use crate::EntityRuntimeState;
14use crate::{
15    CheckObjectStatus, CheckResult, CheckResults, CheckerRegistry, ContextError,
16    EntityDataServiceBehavior, EntityDataServiceBehaviorRegistry, EntityGraphBuilder,
17    EntityRegistry, GraphNode, InMemoryEntityGraphDecoderRegistry, InternalIdGenerator, Language,
18    MetadataStore, ObjectLocation, RawAuditEvent, RawAuditEventSink, RequestPolicy, RuntimeError,
19    local_id_generator,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ContextEntityRef {
24    pub entity_type: String,
25    pub id: u64,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ContextRootError {
30    pub expected_entity_type: String,
31    pub actual_root: Option<ContextEntityRef>,
32}
33
34impl std::fmt::Display for ContextRootError {
35    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match &self.actual_root {
37            None => write!(
38                formatter,
39                "active root {} is missing from UserContext",
40                self.expected_entity_type
41            ),
42            Some(actual) => write!(
43                formatter,
44                "active root type is {}, expected {}",
45                actual.entity_type, self.expected_entity_type
46            ),
47        }
48    }
49}
50
51impl std::error::Error for ContextRootError {}
52
53#[cfg(test)]
54mod active_root_tests {
55    use super::UserContext;
56
57    #[test]
58    fn active_root_is_typed_and_fails_closed() {
59        let context = UserContext::new().with_active_root("Tenant", 42);
60        assert_eq!(context.require_active_root("Tenant").unwrap().id, 42);
61        assert!(context.require_active_root("Organization").is_err());
62        assert!(UserContext::new().require_active_root("Tenant").is_err());
63    }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct ContinuousPageCursor {
68    pub cursor_id: String,
69    pub query_key: String,
70    pub entity: String,
71    pub direction: teaql_core::SortDirection,
72    pub boundary: Value,
73    pub page_size: u64,
74    pub next_offset: u64,
75    pub expires_at: SystemTime,
76}
77
78#[async_trait::async_trait]
79pub trait ContinuousPageCursorStore: Send + Sync + 'static {
80    async fn get(
81        &self,
82        query_key: &str,
83        target_offset: u64,
84    ) -> Result<Option<ContinuousPageCursor>, String>;
85    async fn put(&self, cursor: ContinuousPageCursor) -> Result<(), String>;
86    async fn invalidate(&self, query_key: &str) -> Result<(), String>;
87}
88
89pub struct InMemoryContinuousPageCursorStore {
90    cursors: Mutex<HashMap<String, ContinuousPageCursor>>,
91    max_entries: usize,
92}
93
94#[derive(Debug, Clone)]
95pub struct RetainedIdSet {
96    pub query_key: String,
97    pub ids: Arc<Vec<u64>>,
98    pub expires_at: SystemTime,
99}
100
101#[async_trait::async_trait]
102pub trait IdSetStore: Send + Sync + 'static {
103    async fn get(&self, query_key: &str) -> Result<Option<RetainedIdSet>, String>;
104    async fn put(&self, id_set: RetainedIdSet) -> Result<(), String>;
105    async fn invalidate(&self, query_key: &str) -> Result<(), String>;
106}
107
108pub struct InMemoryIdSetStore {
109    sets: Mutex<HashMap<String, RetainedIdSet>>,
110    max_entries: usize,
111    max_bytes: usize,
112}
113
114impl Default for InMemoryIdSetStore {
115    fn default() -> Self {
116        Self {
117            sets: Mutex::new(HashMap::new()),
118            max_entries: 64,
119            max_bytes: 256 * 1024 * 1024,
120        }
121    }
122}
123
124impl InMemoryIdSetStore {
125    fn retained_bytes(sets: &HashMap<String, RetainedIdSet>) -> usize {
126        sets.values()
127            .map(|value| value.ids.len().saturating_mul(std::mem::size_of::<u64>()))
128            .sum()
129    }
130}
131
132#[async_trait::async_trait]
133impl IdSetStore for InMemoryIdSetStore {
134    async fn get(&self, query_key: &str) -> Result<Option<RetainedIdSet>, String> {
135        let mut sets = self.sets.lock().map_err(|error| error.to_string())?;
136        if sets
137            .get(query_key)
138            .is_some_and(|value| value.expires_at <= SystemTime::now())
139        {
140            sets.remove(query_key);
141        }
142        Ok(sets.get(query_key).cloned())
143    }
144
145    async fn put(&self, id_set: RetainedIdSet) -> Result<(), String> {
146        let incoming_bytes = id_set.ids.len().saturating_mul(std::mem::size_of::<u64>());
147        if incoming_bytes > self.max_bytes {
148            return Err("ID set exceeds the process-local store memory ceiling".to_owned());
149        }
150        let mut sets = self.sets.lock().map_err(|error| error.to_string())?;
151        sets.retain(|_, value| value.expires_at > SystemTime::now());
152        while sets.len() >= self.max_entries
153            || Self::retained_bytes(&sets).saturating_add(incoming_bytes) > self.max_bytes
154        {
155            let Some(oldest) = sets
156                .iter()
157                .min_by_key(|(_, value)| value.expires_at)
158                .map(|(key, _)| key.clone())
159            else {
160                break;
161            };
162            sets.remove(&oldest);
163        }
164        sets.insert(id_set.query_key.clone(), id_set);
165        Ok(())
166    }
167
168    async fn invalidate(&self, query_key: &str) -> Result<(), String> {
169        self.sets
170            .lock()
171            .map_err(|error| error.to_string())?
172            .remove(query_key);
173        Ok(())
174    }
175}
176
177fn id_set_build_lock(query_key: &str) -> Arc<futures_util::lock::Mutex<()>> {
178    static LOCKS: OnceLock<Mutex<HashMap<String, std::sync::Weak<futures_util::lock::Mutex<()>>>>> =
179        OnceLock::new();
180    let mut locks = LOCKS
181        .get_or_init(|| Mutex::new(HashMap::new()))
182        .lock()
183        .expect("ID set build lock registry poisoned");
184    locks.retain(|_, lock| lock.strong_count() > 0);
185    if let Some(lock) = locks.get(query_key).and_then(std::sync::Weak::upgrade) {
186        return lock;
187    }
188    let lock = Arc::new(futures_util::lock::Mutex::new(()));
189    locks.insert(query_key.to_owned(), Arc::downgrade(&lock));
190    lock
191}
192
193impl Default for InMemoryContinuousPageCursorStore {
194    fn default() -> Self {
195        Self {
196            cursors: Mutex::new(HashMap::new()),
197            max_entries: 4096,
198        }
199    }
200}
201
202#[async_trait::async_trait]
203impl ContinuousPageCursorStore for InMemoryContinuousPageCursorStore {
204    async fn get(
205        &self,
206        query_key: &str,
207        target_offset: u64,
208    ) -> Result<Option<ContinuousPageCursor>, String> {
209        let key = format!("{query_key}:{target_offset}");
210        let mut cursors = self.cursors.lock().map_err(|e| e.to_string())?;
211        if cursors
212            .get(&key)
213            .is_some_and(|cursor| cursor.expires_at <= SystemTime::now())
214        {
215            cursors.remove(&key);
216        }
217        Ok(cursors.get(&key).cloned())
218    }
219
220    async fn put(&self, cursor: ContinuousPageCursor) -> Result<(), String> {
221        let key = format!("{}:{}", cursor.query_key, cursor.next_offset);
222        let mut cursors = self.cursors.lock().map_err(|e| e.to_string())?;
223        if cursors.len() >= self.max_entries {
224            if let Some(expired_or_oldest) = cursors
225                .iter()
226                .min_by_key(|(_, value)| value.expires_at)
227                .map(|(key, _)| key.clone())
228            {
229                cursors.remove(&expired_or_oldest);
230            }
231        }
232        cursors.insert(key, cursor);
233        Ok(())
234    }
235
236    async fn invalidate(&self, query_key: &str) -> Result<(), String> {
237        let prefix = format!("{query_key}:");
238        self.cursors
239            .lock()
240            .map_err(|e| e.to_string())?
241            .retain(|key, _| !key.starts_with(&prefix));
242        Ok(())
243    }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum SqlLogOperation {
248    Select,
249    Insert,
250    Update,
251    Delete,
252    Recover,
253}
254
255impl SqlLogOperation {
256    pub fn is_select(self) -> bool {
257        matches!(self, Self::Select)
258    }
259
260    pub fn is_mutation(self) -> bool {
261        !self.is_select()
262    }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
266pub struct SqlLogOptions {
267    pub select: bool,
268    pub mutation: bool,
269}
270
271impl SqlLogOptions {
272    pub fn disabled() -> Self {
273        Self {
274            select: false,
275            mutation: false,
276        }
277    }
278
279    pub fn select_only() -> Self {
280        Self {
281            select: true,
282            mutation: false,
283        }
284    }
285
286    pub fn mutation_only() -> Self {
287        Self {
288            select: false,
289            mutation: true,
290        }
291    }
292
293    pub fn all() -> Self {
294        Self {
295            select: true,
296            mutation: true,
297        }
298    }
299
300    pub fn enabled_for(self, operation: SqlLogOperation) -> bool {
301        match operation.is_select() {
302            true => self.select,
303            false => self.mutation,
304        }
305    }
306}
307
308#[derive(Debug, Clone, PartialEq)]
309pub struct SqlLogEntry {
310    pub operation: SqlLogOperation,
311    pub sql: String,
312    pub params: Vec<Value>,
313    pub debug_sql: String,
314    pub pretty_sql: String,
315    pub started_at: SystemTime,
316    pub ended_at: SystemTime,
317    pub elapsed: Duration,
318    pub result_count: Option<usize>,
319    pub result_type: Option<String>,
320    pub affected_rows: Option<u64>,
321    pub result_summary: String,
322}
323
324#[derive(Debug, Clone, PartialEq)]
325pub struct UnifiedLogEntry {
326    pub timestamp: SystemTime,
327    pub user_identifier: Option<String>,
328    pub trace_chain: Vec<teaql_core::TraceNode>,
329    pub payload: LogPayload,
330}
331
332#[derive(Debug, Clone, PartialEq)]
333pub enum LogPayload {
334    Sql(SqlLogEntry),
335    Info(InfoLogEntry),
336}
337
338#[derive(Debug, Clone, PartialEq)]
339pub struct InfoLogEntry {
340    pub message: String,
341}
342
343#[derive(Clone, Default)]
344pub struct UnifiedLogBuffer {
345    pub entries: std::sync::Arc<Mutex<Vec<UnifiedLogEntry>>>,
346}
347
348/// Context-owned proof required by the provider SPI. Its private field prevents
349/// application crates from invoking a schema provider directly.
350///
351/// ```compile_fail
352/// let _ = teaql_runtime::SchemaInvocation { _context_owned: () };
353/// ```
354pub struct SchemaInvocation {
355    _context_owned: (),
356}
357
358pub trait SchemaProvider: Send + Sync {
359    fn ensure_schema<'a>(
360        &'a self,
361        context: &'a UserContext,
362        invocation: &'a SchemaInvocation,
363    ) -> Pin<Box<dyn Future<Output = Result<(), RuntimeError>> + Send + 'a>>;
364}
365
366#[derive(Clone, Debug, PartialEq, Eq)]
367pub enum FixEvidenceSource {
368    Clock,
369    Context,
370}
371
372#[derive(Clone, Debug, PartialEq, Eq)]
373pub struct FixEvidence {
374    pub entity_type: String,
375    pub model_path: String,
376    pub source: FixEvidenceSource,
377    pub source_label: String,
378}
379
380impl FixEvidence {
381    pub fn new(entity_type: &str, model_path: &str, source: FixEvidenceSource, source_label: &str) -> Self {
382        assert!(!entity_type.trim().is_empty(), "entity_type must not be blank");
383        assert!(!model_path.trim().is_empty(), "model_path must not be blank");
384        assert!(!source_label.trim().is_empty(), "source_label must not be blank");
385        let normalized = source_label.to_ascii_lowercase();
386        assert!(!normalized.contains("authorization") && !normalized.contains("cookie") && !normalized.contains("token="),
387            "source_label must be a safe framework label");
388        Self { entity_type: entity_type.to_owned(), model_path: model_path.to_owned(), source, source_label: source_label.to_owned() }
389    }
390}
391
392pub struct UserContext {
393    active_root: Option<ContextEntityRef>,
394    pub(crate) metadata: Option<Box<dyn MetadataStore>>,
395    pub(crate) entity_registry: Option<Box<dyn EntityRegistry>>,
396    pub(crate) entity_graph_decoders: InMemoryEntityGraphDecoderRegistry,
397    pub(crate) entity_data_service_behavior_registry:
398        Option<Box<dyn EntityDataServiceBehaviorRegistry>>,
399    pub(crate) request_policy: Option<Box<dyn RequestPolicy>>,
400    pub(crate) checker_registry: Option<Box<dyn CheckerRegistry>>,
401    pub(crate) event_sink: Option<Box<dyn RawAuditEventSink>>,
402    pub(crate) custom_event_sink: Option<Box<dyn crate::SafeAuditEventSink>>,
403    pub(crate) internal_id_generator: Option<Box<dyn InternalIdGenerator>>,
404    schema_provider: Option<Box<dyn SchemaProvider>>,
405    language: Language,
406    i18n_catalog: Arc<crate::I18nCatalog>,
407    typed_resources: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
408    named_resources: BTreeMap<String, Box<dyn Any + Send + Sync>>,
409    locals: BTreeMap<String, Value>,
410    pub(crate) initial_graphs: Vec<GraphNode>,
411    pub(crate) root_graphs: Vec<GraphNode>,
412    entity_runtime_state: EntityRuntimeState,
413    sql_log_options: SqlLogOptions,
414    sql_log_entries: Mutex<Vec<SqlLogEntry>>,
415    user_identifier: Option<String>,
416    timezone: Option<String>,
417    trace_id: String,
418    continuous_page_cursor_store: std::sync::Arc<dyn ContinuousPageCursorStore>,
419    continuous_page_observation: Mutex<(String, Option<String>)>,
420    id_set_store: Arc<dyn IdSetStore>,
421    id_set_observation: Mutex<(String, Option<u64>)>,
422    local_lock_owner: u64,
423    remote_lock_owner: String,
424    runtime_telemetry: Arc<dyn crate::RuntimeTelemetry>,
425    last_fix_evidence: Mutex<Vec<FixEvidence>>,
426}
427
428#[derive(Clone, Copy)]
429struct LocalLockEntry {
430    owner: u64,
431    expires_at: Option<Instant>,
432}
433
434#[derive(Default)]
435struct ProcessLocalLocks {
436    entries: Mutex<HashMap<String, LocalLockEntry>>,
437    changed: Condvar,
438}
439
440static PROCESS_LOCAL_LOCKS: OnceLock<ProcessLocalLocks> = OnceLock::new();
441static NEXT_LOCAL_LOCK_OWNER: AtomicU64 = AtomicU64::new(1);
442
443impl Default for UserContext {
444    fn default() -> Self {
445        let pid = std::process::id();
446        let thread_id_str = format!("{:?}", std::thread::current().id());
447        let numeric_thread_id = thread_id_str
448            .strip_prefix("ThreadId(")
449            .and_then(|s| s.strip_suffix(")"))
450            .unwrap_or(&thread_id_str);
451        let os_user = std::env::var("USER")
452            .or_else(|_| std::env::var("USERNAME"))
453            .unwrap_or_else(|_| "main".to_owned());
454        let user_id = format!("{os_user}@pid-{pid}.tid-{numeric_thread_id}");
455        let owner_sequence = NEXT_LOCAL_LOCK_OWNER.fetch_add(1, Ordering::Relaxed);
456        Self {
457            active_root: None,
458            metadata: None,
459            entity_registry: None,
460            entity_graph_decoders: InMemoryEntityGraphDecoderRegistry::default(),
461            entity_data_service_behavior_registry: None,
462            request_policy: None,
463            checker_registry: None,
464            event_sink: None,
465            custom_event_sink: None,
466            internal_id_generator: None,
467            schema_provider: None,
468            language: Language::default(),
469            i18n_catalog: crate::I18nCatalog::builtin().clone(),
470            typed_resources: HashMap::new(),
471            named_resources: BTreeMap::new(),
472            locals: BTreeMap::new(),
473            initial_graphs: Vec::new(),
474            root_graphs: Vec::new(),
475            entity_runtime_state: EntityRuntimeState::default(),
476            sql_log_options: SqlLogOptions::all(),
477            sql_log_entries: Mutex::new(Vec::new()),
478            user_identifier: Some(user_id),
479            timezone: Some("UTC".to_owned()),
480            trace_id: format!(
481                "req-{pid}-{numeric_thread_id}-{:x}",
482                std::time::SystemTime::now()
483                    .duration_since(std::time::UNIX_EPOCH)
484                    .unwrap_or_default()
485                    .as_micros()
486            ),
487            continuous_page_cursor_store: std::sync::Arc::new(
488                InMemoryContinuousPageCursorStore::default(),
489            ),
490            continuous_page_observation: Mutex::new(("DISABLED".to_owned(), None)),
491            id_set_store: Arc::new(InMemoryIdSetStore::default()),
492            id_set_observation: Mutex::new(("ID_SET_DISABLED".to_owned(), None)),
493            local_lock_owner: owner_sequence,
494            remote_lock_owner: format!(
495                "teaql:{pid}:{owner_sequence}:{}",
496                SystemTime::now()
497                    .duration_since(SystemTime::UNIX_EPOCH)
498                    .unwrap_or_default()
499                    .as_nanos()
500            ),
501            runtime_telemetry: Arc::new(crate::NoopRuntimeTelemetry),
502            last_fix_evidence: Mutex::new(Vec::new()),
503        }
504    }
505}
506
507#[async_trait::async_trait]
508pub trait DataStore: Send + Sync + 'static {
509    async fn get(&self, key: &str) -> Option<Value>;
510    async fn put(&self, key: &str, value: Value, timeout_seconds: Option<u64>);
511    async fn remove(&self, key: &str);
512}
513
514/// Provider-neutral distributed lock boundary.
515///
516/// Implementations must associate an acquired lock with `owner_token` and
517/// release it only while that token still owns the key. A zero timeout is one
518/// non-blocking attempt; a zero expiry means no automatic lease expiry.
519#[async_trait::async_trait]
520pub trait RemoteLockProvider: Send + Sync + 'static {
521    async fn try_remote_lock(
522        &self,
523        key: &str,
524        owner_token: &str,
525        timeout_millis: u64,
526        expire_millis: u64,
527    ) -> bool;
528
529    async fn unlock_remote(&self, key: &str, owner_token: &str) -> bool;
530}
531
532#[derive(Default)]
533pub struct InMemoryDataStore {
534    cache: std::sync::RwLock<HashMap<String, (Value, Option<std::time::Instant>)>>,
535}
536
537#[async_trait::async_trait]
538impl DataStore for InMemoryDataStore {
539    async fn get(&self, key: &str) -> Option<Value> {
540        let lock = self.cache.read().unwrap();
541        if let Some((val, expires_at)) = lock.get(key) {
542            if let Some(exp) = expires_at {
543                if std::time::Instant::now() > *exp {
544                    return None;
545                }
546            }
547            return Some(val.clone());
548        }
549        None
550    }
551
552    async fn put(&self, key: &str, value: Value, timeout_seconds: Option<u64>) {
553        let mut lock = self.cache.write().unwrap();
554        let expires_at = timeout_seconds
555            .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs));
556        lock.insert(key.to_string(), (value, expires_at));
557    }
558
559    async fn remove(&self, key: &str) {
560        let mut lock = self.cache.write().unwrap();
561        lock.remove(key);
562    }
563}
564
565impl UserContext {
566    pub fn new() -> Self {
567        Self::default()
568    }
569
570    pub fn with_active_root(mut self, entity_type: impl Into<String>, id: u64) -> Self {
571        let entity_type = entity_type.into();
572        assert!(
573            !entity_type.trim().is_empty(),
574            "active root entity type is required"
575        );
576        assert!(id > 0, "active root id must be positive");
577        self.active_root = Some(ContextEntityRef { entity_type, id });
578        self
579    }
580
581    pub fn require_active_root(
582        &self,
583        expected_entity_type: &str,
584    ) -> Result<&ContextEntityRef, ContextRootError> {
585        match &self.active_root {
586            Some(root) if root.entity_type == expected_entity_type => Ok(root),
587            actual_root => Err(ContextRootError {
588                expected_entity_type: expected_entity_type.to_owned(),
589                actual_root: actual_root.clone(),
590            }),
591        }
592    }
593
594    pub(crate) fn active_root_ref(&self) -> Option<&ContextEntityRef> {
595        self.active_root.as_ref()
596    }
597
598    pub fn with_runtime_telemetry(mut self, telemetry: Arc<dyn crate::RuntimeTelemetry>) -> Self {
599        self.runtime_telemetry = telemetry;
600        self
601    }
602
603    pub fn set_runtime_telemetry(&mut self, telemetry: Arc<dyn crate::RuntimeTelemetry>) {
604        self.runtime_telemetry = telemetry;
605    }
606
607    pub fn runtime_telemetry(&self) -> &Arc<dyn crate::RuntimeTelemetry> {
608        &self.runtime_telemetry
609    }
610
611    pub(crate) fn runtime_telemetry_is_noop(&self) -> bool {
612        self.runtime_telemetry.is_noop()
613    }
614
615    pub fn start_runtime_operation(
616        &self,
617        operation: crate::RuntimeOperation,
618    ) -> crate::FailOpenRuntimeTelemetryScope {
619        crate::start_runtime_operation(&self.runtime_telemetry, operation)
620    }
621
622    pub fn try_local_lock(&self, key: &str, timeout_millis: u64, expire_millis: u64) -> bool {
623        let locks = PROCESS_LOCAL_LOCKS.get_or_init(ProcessLocalLocks::default);
624        let deadline = Instant::now() + Duration::from_millis(timeout_millis);
625        let mut entries = locks.entries.lock().expect("local lock state poisoned");
626        loop {
627            let now = Instant::now();
628            match entries.get(key).copied() {
629                None => {
630                    entries.insert(
631                        key.to_owned(),
632                        LocalLockEntry {
633                            owner: self.local_lock_owner,
634                            expires_at: (expire_millis > 0)
635                                .then(|| now + Duration::from_millis(expire_millis)),
636                        },
637                    );
638                    return true;
639                }
640                Some(current)
641                    if current.owner == self.local_lock_owner
642                        || current.expires_at.is_some_and(|expiry| now >= expiry) =>
643                {
644                    entries.insert(
645                        key.to_owned(),
646                        LocalLockEntry {
647                            owner: self.local_lock_owner,
648                            expires_at: (expire_millis > 0)
649                                .then(|| now + Duration::from_millis(expire_millis)),
650                        },
651                    );
652                    return true;
653                }
654                Some(current) => {
655                    if timeout_millis == 0 || now >= deadline {
656                        return false;
657                    }
658                    let wake_after = current
659                        .expires_at
660                        .map(|expiry| expiry.saturating_duration_since(now))
661                        .unwrap_or_else(|| deadline.saturating_duration_since(now))
662                        .min(deadline.saturating_duration_since(now));
663                    let waited = locks
664                        .changed
665                        .wait_timeout(entries, wake_after)
666                        .expect("local lock state poisoned");
667                    entries = waited.0;
668                }
669            }
670        }
671    }
672
673    pub fn unlock_local(&self, key: &str) {
674        let locks = PROCESS_LOCAL_LOCKS.get_or_init(ProcessLocalLocks::default);
675        let mut entries = locks.entries.lock().expect("local lock state poisoned");
676        if entries
677            .get(key)
678            .is_some_and(|entry| entry.owner == self.local_lock_owner)
679        {
680            entries.remove(key);
681            locks.changed.notify_all();
682        }
683    }
684
685    /// Attempts to acquire a provider-backed distributed lock.
686    ///
687    /// A missing provider remains a no-op success, matching the optional
688    /// Remote Lock boundary in the other TeaQL runtimes. Install an
689    /// `Arc<dyn RemoteLockProvider>` resource to enable distributed exclusion.
690    pub async fn try_remote_lock(
691        &self,
692        key: &str,
693        timeout_millis: u64,
694        expire_millis: u64,
695    ) -> bool {
696        match self.get_resource::<Arc<dyn RemoteLockProvider>>() {
697            Some(provider) => {
698                provider
699                    .try_remote_lock(key, &self.remote_lock_owner, timeout_millis, expire_millis)
700                    .await
701            }
702            None => true,
703        }
704    }
705
706    /// Releases a distributed lock only when this context still owns it.
707    pub async fn unlock_remote(&self, key: &str) -> bool {
708        match self.get_resource::<Arc<dyn RemoteLockProvider>>() {
709            Some(provider) => provider.unlock_remote(key, &self.remote_lock_owner).await,
710            None => true,
711        }
712    }
713
714    pub fn user_identifier(&self) -> Option<&str> {
715        self.user_identifier.as_deref()
716    }
717
718    pub fn set_user_identifier(&mut self, user_identifier: impl Into<String>) {
719        self.user_identifier = Some(user_identifier.into());
720    }
721
722    pub fn set_continuous_page_cursor_store(
723        &mut self,
724        store: std::sync::Arc<dyn ContinuousPageCursorStore>,
725    ) {
726        self.continuous_page_cursor_store = store;
727    }
728
729    pub fn continuous_page_plan(&self) -> Option<String> {
730        self.continuous_page_observation
731            .lock()
732            .ok()
733            .map(|value| value.0.clone())
734    }
735
736    pub fn continuous_page_cursor_id(&self) -> Option<String> {
737        self.continuous_page_observation
738            .lock()
739            .ok()
740            .and_then(|value| value.1.clone())
741    }
742
743    pub(crate) fn observe_continuous_page(
744        &self,
745        plan: impl Into<String>,
746        cursor_id: Option<String>,
747    ) {
748        if let Ok(mut observation) = self.continuous_page_observation.lock() {
749            *observation = (plan.into(), cursor_id);
750        }
751    }
752
753    pub(crate) fn continuous_page_cursor_store(&self) -> &dyn ContinuousPageCursorStore {
754        self.continuous_page_cursor_store.as_ref()
755    }
756
757    pub fn set_id_set_store(&mut self, store: Arc<dyn IdSetStore>) {
758        self.id_set_store = store;
759    }
760
761    pub fn id_set_plan(&self) -> Option<String> {
762        self.id_set_observation
763            .lock()
764            .ok()
765            .map(|observation| observation.0.clone())
766    }
767
768    pub fn id_set_count(&self) -> Option<u64> {
769        self.id_set_observation
770            .lock()
771            .ok()
772            .and_then(|observation| observation.1)
773    }
774
775    pub(crate) fn observe_id_set(&self, plan: impl Into<String>, count: Option<u64>) {
776        if let Ok(mut observation) = self.id_set_observation.lock() {
777            *observation = (plan.into(), count);
778        }
779    }
780
781    pub(crate) fn id_set_store(&self) -> &dyn IdSetStore {
782        self.id_set_store.as_ref()
783    }
784
785    pub(crate) fn id_set_build_lock(&self, query_key: &str) -> Arc<futures_util::lock::Mutex<()>> {
786        id_set_build_lock(query_key)
787    }
788
789    pub fn with_user_identifier(mut self, user_identifier: impl Into<String>) -> Self {
790        self.user_identifier = Some(user_identifier.into());
791        self
792    }
793
794    pub fn set_user_identifier_option(&mut self, user_identifier: Option<String>) {
795        self.user_identifier = user_identifier;
796    }
797
798    pub fn with_user_identifier_option(mut self, user_identifier: Option<String>) -> Self {
799        self.user_identifier = user_identifier;
800        self
801    }
802
803    pub fn timezone(&self) -> Option<&str> {
804        self.timezone.as_deref()
805    }
806
807    pub fn set_timezone(&mut self, timezone: impl Into<String>) {
808        self.timezone = Some(timezone.into());
809    }
810
811    pub fn with_timezone(mut self, timezone: impl Into<String>) -> Self {
812        self.timezone = Some(timezone.into());
813        self
814    }
815
816    pub fn trace_id(&self) -> &str {
817        &self.trace_id
818    }
819
820    pub fn set_trace_id(&mut self, trace_id: impl Into<String>) {
821        self.trace_id = trace_id.into();
822    }
823
824    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
825        self.trace_id = trace_id.into();
826        self
827    }
828
829    pub fn with_module(mut self, module: crate::RuntimeModule) -> Self {
830        module.apply_to(&mut self);
831        self
832    }
833
834    pub fn entity_runtime_state(&self) -> EntityRuntimeState {
835        // UserContext owns only the immutable identity-graph anchor. Every query/new-entity
836        // operation receives fresh mutation state, even when the same context is reused.
837        EntityRuntimeState::fresh_with_shared_graph(&self.entity_runtime_state)
838    }
839
840    pub fn initial_graphs(&self) -> &[GraphNode] {
841        &self.initial_graphs
842    }
843
844    pub fn set_initial_graphs(&mut self, graphs: Vec<GraphNode>) {
845        self.initial_graphs = graphs;
846    }
847
848    pub fn root_graphs(&self) -> &[GraphNode] {
849        &self.root_graphs
850    }
851
852    pub fn set_root_graphs(&mut self, graphs: Vec<GraphNode>) {
853        self.root_graphs = graphs;
854    }
855
856    pub fn with_metadata(mut self, metadata: impl MetadataStore + 'static) -> Self {
857        self.metadata = Some(Box::new(metadata));
858        self
859    }
860
861    pub fn set_metadata(&mut self, metadata: impl MetadataStore + 'static) {
862        self.metadata = Some(Box::new(metadata));
863    }
864
865    pub fn with_entity_registry(mut self, registry: impl EntityRegistry + 'static) -> Self {
866        self.entity_registry = Some(Box::new(registry));
867        self
868    }
869
870    pub fn set_entity_registry(&mut self, registry: impl EntityRegistry + 'static) {
871        self.entity_registry = Some(Box::new(registry));
872    }
873
874    pub fn set_entity_graph_decoder_registry(
875        &mut self,
876        registry: InMemoryEntityGraphDecoderRegistry,
877    ) {
878        self.entity_graph_decoders = registry;
879    }
880
881    pub(crate) fn has_entity_graph_decoder(&self, entity: &str) -> bool {
882        self.entity_graph_decoders.contains(entity)
883    }
884
885    pub(crate) fn decode_compact_entity_into_graph(
886        &self,
887        entity: &str,
888        row: teaql_core::CompactRow,
889        root: &EntityRuntimeState,
890        graph: &mut EntityGraphBuilder,
891    ) -> Result<(), teaql_core::EntityError> {
892        self.entity_graph_decoders
893            .decode_compact(entity, row, root, graph)
894    }
895
896    pub(crate) fn decode_compact_entity_list_into_graph(
897        &self,
898        entity: &str,
899        rows: Vec<teaql_core::CompactRow>,
900        root: &EntityRuntimeState,
901        graph: &mut EntityGraphBuilder,
902        owner_entity: &str,
903        owner_id: u64,
904        relation: &str,
905    ) -> Result<(), teaql_core::EntityError> {
906        self.entity_graph_decoders.decode_compact_list(
907            entity,
908            rows,
909            root,
910            graph,
911            owner_entity,
912            owner_id,
913            relation,
914        )
915    }
916
917    pub(crate) fn decode_compact_entity_batch_into_graph(
918        &self,
919        entity: &str,
920        rows: Vec<teaql_core::CompactRow>,
921        root: &EntityRuntimeState,
922        graph: &mut EntityGraphBuilder,
923    ) -> Result<(), teaql_core::EntityError> {
924        self.entity_graph_decoders
925            .decode_compact_batch(entity, rows, root, graph)
926    }
927
928    pub(crate) fn decode_compact_entity_option_into_graph(
929        &self,
930        entity: &str,
931        rows: Vec<teaql_core::CompactRow>,
932        root: &EntityRuntimeState,
933        graph: &mut EntityGraphBuilder,
934        owner_entity: &str,
935        owner_id: u64,
936        relation: &str,
937    ) -> Result<(), teaql_core::EntityError> {
938        self.entity_graph_decoders.decode_compact_option(
939            entity,
940            rows,
941            root,
942            graph,
943            owner_entity,
944            owner_id,
945            relation,
946        )
947    }
948
949    pub fn with_entity_data_service_behavior_registry(
950        mut self,
951        registry: impl EntityDataServiceBehaviorRegistry + 'static,
952    ) -> Self {
953        self.entity_data_service_behavior_registry = Some(Box::new(registry));
954        self
955    }
956
957    pub fn set_entity_data_service_behavior_registry(
958        &mut self,
959        registry: impl EntityDataServiceBehaviorRegistry + 'static,
960    ) {
961        self.entity_data_service_behavior_registry = Some(Box::new(registry));
962    }
963
964    pub fn with_request_policy(mut self, policy: impl RequestPolicy + 'static) -> Self {
965        self.request_policy = Some(Box::new(policy));
966        self
967    }
968
969    pub fn set_request_policy(&mut self, policy: impl RequestPolicy + 'static) {
970        self.request_policy = Some(Box::new(policy));
971    }
972
973    pub fn clear_request_policy(&mut self) {
974        self.request_policy = None;
975    }
976
977    pub fn with_checker_registry(mut self, registry: impl CheckerRegistry + 'static) -> Self {
978        self.checker_registry = Some(Box::new(registry));
979        self
980    }
981
982    pub fn set_checker_registry(&mut self, registry: impl CheckerRegistry + 'static) {
983        self.checker_registry = Some(Box::new(registry));
984    }
985
986    pub(crate) fn with_event_sink(mut self, sink: impl RawAuditEventSink + 'static) -> Self {
987        self.event_sink = Some(Box::new(sink));
988        self
989    }
990
991    pub(crate) fn set_event_sink(&mut self, sink: impl RawAuditEventSink + 'static) {
992        self.event_sink = Some(Box::new(sink));
993    }
994
995    pub fn with_custom_event_sink(
996        mut self,
997        sink: impl crate::SafeAuditEventSink + 'static,
998    ) -> Self {
999        self.custom_event_sink = Some(Box::new(sink));
1000        self
1001    }
1002
1003    pub fn set_custom_event_sink(&mut self, sink: impl crate::SafeAuditEventSink + 'static) {
1004        self.custom_event_sink = Some(Box::new(sink));
1005    }
1006
1007    pub fn with_internal_id_generator(
1008        mut self,
1009        generator: impl InternalIdGenerator + 'static,
1010    ) -> Self {
1011        self.internal_id_generator = Some(Box::new(generator));
1012        self
1013    }
1014
1015    pub fn set_internal_id_generator(&mut self, generator: impl InternalIdGenerator + 'static) {
1016        self.internal_id_generator = Some(Box::new(generator));
1017    }
1018
1019    pub fn with_schema_provider(mut self, provider: impl SchemaProvider + 'static) -> Self {
1020        self.schema_provider = Some(Box::new(provider));
1021        self
1022    }
1023
1024    pub fn set_schema_provider(&mut self, provider: impl SchemaProvider + 'static) {
1025        self.schema_provider = Some(Box::new(provider));
1026    }
1027
1028    pub async fn ensure_schema(&self) -> Result<(), RuntimeError> {
1029        let provider = self
1030            .schema_provider
1031            .as_ref()
1032            .ok_or_else(|| RuntimeError::Schema("missing schema provider".to_owned()))?;
1033        let invocation = SchemaInvocation { _context_owned: () };
1034        provider.ensure_schema(self, &invocation).await
1035    }
1036
1037    pub fn with_language(mut self, language: Language) -> Self {
1038        self.language = language;
1039        self
1040    }
1041
1042    pub fn set_language(&mut self, language: Language) {
1043        self.language = language;
1044    }
1045
1046    pub fn with_i18n_catalog(mut self, catalog: Arc<crate::I18nCatalog>) -> Self {
1047        self.i18n_catalog = catalog;
1048        self
1049    }
1050
1051    pub fn set_i18n_catalog(&mut self, catalog: Arc<crate::I18nCatalog>) {
1052        self.i18n_catalog = catalog;
1053    }
1054
1055    pub fn with_sql_log_options(mut self, options: SqlLogOptions) -> Self {
1056        self.sql_log_options = options;
1057        self
1058    }
1059
1060    pub fn set_sql_log_options(&mut self, options: SqlLogOptions) {
1061        self.sql_log_options = options;
1062    }
1063
1064    pub fn enable_select_sql_log(&mut self) {
1065        self.sql_log_options.select = true;
1066    }
1067
1068    pub fn enable_mutation_sql_log(&mut self) {
1069        self.sql_log_options.mutation = true;
1070    }
1071
1072    pub fn enable_all_sql_log(&mut self) {
1073        self.sql_log_options = SqlLogOptions::all();
1074    }
1075
1076    pub fn disable_sql_log(&mut self) {
1077        self.sql_log_options = SqlLogOptions::disabled();
1078        self.clear_sql_logs();
1079    }
1080
1081    pub fn sql_log_options(&self) -> SqlLogOptions {
1082        self.sql_log_options
1083    }
1084
1085    pub fn sql_logs(&self) -> Vec<SqlLogEntry> {
1086        self.sql_log_entries
1087            .lock()
1088            .map(|entries| entries.clone())
1089            .unwrap_or_default()
1090    }
1091
1092    pub fn clear_sql_logs(&self) {
1093        if let Ok(mut entries) = self.sql_log_entries.lock() {
1094            entries.clear();
1095        }
1096    }
1097
1098    pub(crate) fn record_sql_log(
1099        &self,
1100        operation: SqlLogOperation,
1101        query: &CompiledQuery,
1102        database_kind: DatabaseKind,
1103        started_at: SystemTime,
1104        ended_at: SystemTime,
1105        elapsed: Duration,
1106        result_count: Option<usize>,
1107        result_type: Option<String>,
1108        affected_rows: Option<u64>,
1109        trace_chain: Vec<teaql_core::TraceNode>,
1110    ) {
1111        if !self.sql_log_options.enabled_for(operation) {
1112            return;
1113        }
1114        let debug_sql = query.debug_sql(database_kind);
1115        let result_summary = sql_result_summary(
1116            operation,
1117            result_count,
1118            result_type.as_deref(),
1119            affected_rows,
1120            &debug_sql,
1121        );
1122
1123        let sql_log_entry = SqlLogEntry {
1124            operation,
1125            sql: query.sql.clone(),
1126            params: query.params.clone(),
1127            pretty_sql: pretty_sql(&debug_sql),
1128            debug_sql: debug_sql.clone(),
1129            started_at,
1130            ended_at,
1131            elapsed,
1132            result_summary: result_summary.clone(),
1133            result_count,
1134            result_type,
1135            affected_rows,
1136        };
1137
1138        if let Ok(mut entries) = self.sql_log_entries.lock() {
1139            // Keep sql_log_entries backwards-compatible for now if needed,
1140            // wait, we modified SqlLogEntry. We can just push it directly since we removed comment.
1141            // Wait, we need to push a cloned SqlLogEntry since it doesn't have comment.
1142            entries.push(sql_log_entry.clone());
1143        }
1144
1145        if let Some(buf) = self.get_resource::<UnifiedLogBuffer>() {
1146            if let Ok(mut entries) = buf.entries.lock() {
1147                entries.push(UnifiedLogEntry {
1148                    timestamp: started_at,
1149                    user_identifier: self.user_identifier.clone(),
1150                    trace_chain: trace_chain.clone(),
1151                    payload: LogPayload::Sql(sql_log_entry.clone()),
1152                });
1153            }
1154        }
1155
1156        crate::log_formatter::LogManager::write_sql_log(&trace_chain, &sql_log_entry);
1157    }
1158
1159    pub(crate) fn record_metadata_log(&self, metadata: &teaql_data_service::ExecutionMetadata) {
1160        let operation = match metadata.operation {
1161            teaql_data_service::DataServiceOperation::Query => SqlLogOperation::Select,
1162            teaql_data_service::DataServiceOperation::Insert => SqlLogOperation::Insert,
1163            teaql_data_service::DataServiceOperation::Update => SqlLogOperation::Update,
1164            teaql_data_service::DataServiceOperation::Delete => SqlLogOperation::Delete,
1165            teaql_data_service::DataServiceOperation::Recover => SqlLogOperation::Update,
1166            teaql_data_service::DataServiceOperation::Batch => SqlLogOperation::Update,
1167            teaql_data_service::DataServiceOperation::Schema => SqlLogOperation::Update,
1168        };
1169        if !self.sql_log_options.enabled_for(operation) {
1170            return;
1171        }
1172        if let Some(debug_sql) = &metadata.debug_query {
1173            let sql_log_entry = SqlLogEntry {
1174                operation,
1175                sql: metadata.parameterized_query.clone().unwrap_or_default(),
1176                params: metadata.params.clone(),
1177                pretty_sql: pretty_sql(debug_sql),
1178                debug_sql: debug_sql.clone(),
1179                started_at: metadata.started_at,
1180                ended_at: metadata.ended_at,
1181                elapsed: metadata
1182                    .ended_at
1183                    .duration_since(metadata.started_at)
1184                    .unwrap_or_default(),
1185                result_count: metadata.result_count,
1186                result_type: None, // Not directly available
1187                affected_rows: metadata.affected_rows,
1188                result_summary: String::new(), // We can synthesize this if needed, or leave it empty/basic
1189            };
1190
1191            // synthesize a summary for the log
1192            let mut summary = String::new();
1193            if let Some(c) = metadata.result_count {
1194                summary = format!("{} rows returned", c);
1195            } else if let Some(a) = metadata.affected_rows {
1196                summary = format!("{} rows affected", a);
1197            }
1198
1199            let mut final_entry = sql_log_entry;
1200            final_entry.result_summary = summary;
1201
1202            if let Ok(mut entries) = self.sql_log_entries.lock() {
1203                entries.push(final_entry.clone());
1204            }
1205
1206            if let Some(buf) = self.get_resource::<UnifiedLogBuffer>() {
1207                if let Ok(mut entries) = buf.entries.lock() {
1208                    entries.push(UnifiedLogEntry {
1209                        timestamp: metadata.started_at,
1210                        user_identifier: self.user_identifier.clone(),
1211                        trace_chain: metadata.trace_chain.clone(),
1212                        payload: LogPayload::Sql(final_entry.clone()),
1213                    });
1214                }
1215            }
1216
1217            crate::log_formatter::LogManager::write_sql_log(&metadata.trace_chain, &final_entry);
1218        }
1219    }
1220
1221    pub fn language(&self) -> Language {
1222        self.language
1223    }
1224
1225    pub fn set_language_code(&mut self, code: &str) -> Result<(), RuntimeError> {
1226        let Some(language) = Language::from_code(code) else {
1227            return Err(RuntimeError::UnsupportedLocale(code.to_owned()));
1228        };
1229        self.language = language;
1230        Ok(())
1231    }
1232
1233    pub fn set_locale_code(&mut self, code: &str) -> Result<(), RuntimeError> {
1234        self.set_language_code(code)
1235    }
1236
1237    pub fn generate_id(&self, entity: &str) -> Result<Option<u64>, RuntimeError> {
1238        self.internal_id_generator
1239            .as_ref()
1240            .map(|generator| generator.generate_id(entity))
1241            .transpose()
1242    }
1243
1244    pub fn next_id(&self, entity: &str) -> Result<u64, RuntimeError> {
1245        match self.generate_id(entity)? {
1246            Some(id) => Ok(id),
1247            None => local_id_generator().generate_id(entity),
1248        }
1249    }
1250
1251    pub fn entity(&self, name: &str) -> Option<&EntityDescriptor> {
1252        self.metadata
1253            .as_ref()
1254            .and_then(|metadata| metadata.entity(name))
1255    }
1256
1257    pub fn all_entities(&self) -> Vec<&EntityDescriptor> {
1258        self.metadata
1259            .as_ref()
1260            .map(|metadata| metadata.all_entities())
1261            .unwrap_or_default()
1262    }
1263
1264    pub fn require_entity(&self, name: &str) -> Result<&EntityDescriptor, RuntimeError> {
1265        self.entity(name)
1266            .ok_or_else(|| RuntimeError::MissingEntity(name.to_owned()))
1267    }
1268
1269    pub fn insert_resource<T>(&mut self, resource: T)
1270    where
1271        T: Send + Sync + 'static,
1272    {
1273        self.typed_resources
1274            .insert(TypeId::of::<T>(), Box::new(resource));
1275    }
1276
1277    pub fn get_resource<T>(&self) -> Option<&T>
1278    where
1279        T: Send + Sync + 'static,
1280    {
1281        self.typed_resources
1282            .get(&TypeId::of::<T>())
1283            .and_then(|value| value.downcast_ref::<T>())
1284    }
1285
1286    pub fn require_resource<T>(&self) -> Result<&T, ContextError>
1287    where
1288        T: Send + Sync + 'static,
1289    {
1290        self.get_resource::<T>()
1291            .ok_or(ContextError::MissingTypedResource(
1292                std::any::type_name::<T>(),
1293            ))
1294    }
1295
1296    pub fn insert_named_resource<T>(&mut self, name: impl Into<String>, resource: T)
1297    where
1298        T: Send + Sync + 'static,
1299    {
1300        self.named_resources.insert(name.into(), Box::new(resource));
1301    }
1302
1303    pub fn get_named_resource<T>(&self, name: &str) -> Option<&T>
1304    where
1305        T: Send + Sync + 'static,
1306    {
1307        self.named_resources
1308            .get(name)
1309            .and_then(|value| value.downcast_ref::<T>())
1310    }
1311
1312    pub fn require_named_resource<T>(&self, name: &str) -> Result<&T, ContextError>
1313    where
1314        T: Send + Sync + 'static,
1315    {
1316        self.get_named_resource::<T>(name)
1317            .ok_or_else(|| ContextError::MissingResource(name.to_owned()))
1318    }
1319
1320    pub fn put_local(&mut self, key: impl Into<String>, value: impl Into<Value>) {
1321        self.locals.insert(key.into(), value.into());
1322    }
1323
1324    pub fn local(&self, key: &str) -> Option<&Value> {
1325        self.locals.get(key)
1326    }
1327
1328    pub fn remove_local(&mut self, key: &str) -> Option<Value> {
1329        self.locals.remove(key)
1330    }
1331
1332    pub fn has_entity_data_service(&self, entity: &str) -> bool {
1333        let in_registry = self
1334            .entity_registry
1335            .as_ref()
1336            .map(|registry| registry.contains(entity))
1337            .unwrap_or(false);
1338        in_registry || self.entity(entity).is_some()
1339    }
1340
1341    pub fn entity_data_service_behavior(
1342        &self,
1343        entity: &str,
1344    ) -> Option<std::sync::Arc<dyn EntityDataServiceBehavior>> {
1345        self.entity_data_service_behavior_registry
1346            .as_ref()
1347            .and_then(|registry| registry.behavior(entity))
1348    }
1349
1350    pub fn has_checker(&self, entity: &str) -> bool {
1351        self.checker_registry
1352            .as_ref()
1353            .and_then(|registry| registry.checker(entity))
1354            .is_some()
1355    }
1356
1357    /// One deterministic clock value for every Fix executed by the current
1358    /// graph save. The task-local scope keeps concurrent saves on one context
1359    /// isolated; standalone checker calls receive their own current value.
1360    pub fn fix_time(&self) -> teaql_core::time::Timestamp {
1361        crate::entity_save::current_graph_fix_time()
1362    }
1363
1364    pub fn record_fix_evidence(&self, evidence: FixEvidence) {
1365        crate::entity_save::record_graph_fix_evidence(evidence);
1366    }
1367
1368    pub(crate) fn replace_last_fix_evidence(&self, evidence: Vec<FixEvidence>) {
1369        *self.last_fix_evidence.lock().unwrap() = evidence;
1370    }
1371
1372    pub fn last_fix_evidence(&self) -> Vec<FixEvidence> {
1373        self.last_fix_evidence.lock().unwrap().clone()
1374    }
1375
1376    pub fn check_and_fix_values(
1377        &self,
1378        entity: &str,
1379        values: &mut crate::EntityValues,
1380    ) -> Result<(), RuntimeError> {
1381        self.check_and_fix_values_at(entity, values, &ObjectLocation::root())
1382    }
1383
1384    pub fn check_and_fix_values_at(
1385        &self,
1386        entity: &str,
1387        values: &mut crate::EntityValues,
1388        location: &ObjectLocation,
1389    ) -> Result<(), RuntimeError> {
1390        let status = CheckObjectStatus::from_values(values);
1391        let checker = self
1392            .checker_registry
1393            .as_ref()
1394            .and_then(|registry| registry.checker(entity));
1395        let mut results = CheckResults::new();
1396        if let Some(checker) = checker {
1397            checker.check_and_fix(self, values, location, &mut results);
1398        }
1399
1400        // Keep runtime validation aligned with the schema generated from the
1401        // same metadata. Custom checkers get the first chance to supply or fix
1402        // a value; afterwards every NOT NULL property must be present on a
1403        // create, and an update must not explicitly clear one.
1404        if let Some(descriptor) = self
1405            .metadata
1406            .as_ref()
1407            .and_then(|metadata| metadata.entity(entity))
1408        {
1409            for property in descriptor
1410                .properties
1411                .iter()
1412                // The optimistic-lock version is runtime-managed. Insert
1413                // preparation assigns its initial value after check/fix, so a
1414                // create caller must never be required to provide it.
1415                .filter(|property| !property.nullable && !property.is_version)
1416            {
1417                let missing = !values.contains_key(&property.name);
1418                let null = matches!(values.get(&property.name), Some(Value::Null));
1419                let property_location = location.clone().member(&property.name);
1420                let already_reported = results.iter().any(|result| {
1421                    result.rule == crate::CheckRule::Required
1422                        && result.location == property_location
1423                });
1424                if ((status.is_create() && missing) || null) && !already_reported {
1425                    results.push(CheckResult::required(property_location));
1426                }
1427            }
1428        }
1429        if results.is_empty() {
1430            return Ok(());
1431        }
1432        self.translate_check_results(&mut results);
1433        Err(RuntimeError::Check(results))
1434    }
1435
1436    pub fn translate_check_results(&self, results: &mut CheckResults) {
1437        for result in results {
1438            if result.message.is_none() {
1439                result.message = Some(
1440                    self.i18n_catalog
1441                        .translate_check_result(self.language, result),
1442                );
1443            }
1444        }
1445    }
1446
1447    pub fn send_event(&self, event: RawAuditEvent) -> Result<(), RuntimeError> {
1448        let scope = self.start_runtime_operation(
1449            crate::RuntimeOperation::new("audit", format!("{}.event", event.entity))
1450                .attribute("teaql.entity.type", event.entity.clone()),
1451        );
1452        let result = self.send_event_inner(event);
1453        match &result {
1454            Ok(()) => scope.success(std::collections::BTreeMap::new()),
1455            Err(_) => scope.failure("audit_error"),
1456        }
1457        result
1458    }
1459
1460    fn send_event_inner(&self, event: RawAuditEvent) -> Result<(), RuntimeError> {
1461        if let Some(sink) = self.event_sink.as_ref() {
1462            sink.on_event(self, &event)?;
1463        }
1464        if let Some(sink) = self.custom_event_sink.as_ref() {
1465            let (mask_fields, max_len) = self
1466                .metadata
1467                .as_ref()
1468                .and_then(|metadata| metadata.entity(&event.entity))
1469                .map(|desc| (desc.audit_mask_fields.clone(), desc.audit_value_max_len))
1470                .unwrap_or_else(|| (vec![], None));
1471
1472            let safe_event = event.build_safe_event(&mask_fields, max_len);
1473            sink.on_safe_event(self, &safe_event)?;
1474        }
1475
1476        crate::log_formatter::LogManager::write_audit_log(&event);
1477
1478        Ok(())
1479    }
1480
1481    pub async fn get_in_store(&self, key: &str) -> Option<Value> {
1482        let store = self.get_resource::<Box<dyn DataStore>>()?;
1483        store.get(key).await
1484    }
1485
1486    pub async fn put_in_store(
1487        &self,
1488        key: &str,
1489        value: impl Into<Value>,
1490        timeout_seconds: Option<u64>,
1491    ) {
1492        if let Some(store) = self.get_resource::<Box<dyn DataStore>>() {
1493            store.put(key, value.into(), timeout_seconds).await;
1494        }
1495    }
1496
1497    pub async fn clear_in_store(&self, key: &str) {
1498        if let Some(store) = self.get_resource::<Box<dyn DataStore>>() {
1499            store.remove(key).await;
1500        }
1501    }
1502}
1503
1504fn extract_id_from_sql(sql: &str) -> Option<String> {
1505    let sql_lower = sql.to_lowercase();
1506    let where_idx = sql_lower.find("where")?;
1507    let where_clause = &sql_lower[where_idx + 5..];
1508
1509    let bytes = where_clause.as_bytes();
1510    let mut i = 0;
1511    while i < bytes.len() {
1512        if i + 1 < bytes.len() && &bytes[i..i + 2] == b"id" {
1513            // Check boundary before
1514            let prev_ok = i == 0 || {
1515                let prev_char = bytes[i - 1] as char;
1516                !prev_char.is_ascii_alphanumeric() && prev_char != '_' && prev_char != '.'
1517            };
1518            // Check boundary after
1519            let next_ok = i + 2 == bytes.len() || {
1520                let next_char = bytes[i + 2] as char;
1521                !next_char.is_ascii_alphanumeric() && next_char != '_'
1522            };
1523
1524            if prev_ok && next_ok {
1525                // Found the standalone "id" word!
1526                // Now look for "=" after it
1527                let mut j = i + 2;
1528                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1529                    j += 1;
1530                }
1531                if j < bytes.len() && bytes[j] == b'=' {
1532                    j += 1;
1533                    while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1534                        j += 1;
1535                    }
1536                    // Now extract the value
1537                    let mut val_str = String::new();
1538                    if j < bytes.len() && bytes[j] == b'\'' {
1539                        j += 1; // consume single quote
1540                        while j < bytes.len() && bytes[j] != b'\'' {
1541                            val_str.push(bytes[j] as char);
1542                            j += 1;
1543                        }
1544                        return Some(val_str);
1545                    }
1546                    // No else needed — falls through to unquoted parsing
1547                    while j < bytes.len() {
1548                        let c = bytes[j] as char;
1549                        if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
1550                            break;
1551                        }
1552                        val_str.push(c);
1553                        j += 1;
1554                    }
1555                    if !val_str.is_empty() {
1556                        return Some(val_str);
1557                    }
1558                }
1559            }
1560        }
1561        i += 1;
1562    }
1563    None
1564}
1565
1566fn sql_result_summary(
1567    operation: SqlLogOperation,
1568    result_count: Option<usize>,
1569    result_type: Option<&str>,
1570    affected_rows: Option<u64>,
1571    debug_sql: &str,
1572) -> String {
1573    match operation {
1574        SqlLogOperation::Select => {
1575            let count = result_count.unwrap_or(0);
1576            match count {
1577                0 => "MISS".to_owned(),
1578                1 => match result_type {
1579                    Some(result_type) => extract_id_from_sql(debug_sql)
1580                        .map(|id| format!("{result_type}({id})"))
1581                        .unwrap_or_else(|| result_type.to_owned()),
1582                    None => "row".to_owned(),
1583                },
1584                _ => match result_type {
1585                    Some(result_type) => format!("{count}*{result_type}"),
1586                    None => format!("{count}*rows"),
1587                },
1588            }
1589        }
1590        _ => {
1591            let affected = affected_rows.unwrap_or(0);
1592            format!("{affected} UPDATED")
1593        }
1594    }
1595}
1596
1597fn pretty_sql(sql: &str) -> String {
1598    let mut pretty = sql.to_owned();
1599    for keyword in [
1600        " FROM ",
1601        " WHERE ",
1602        " GROUP BY ",
1603        " HAVING ",
1604        " ORDER BY ",
1605        " LIMIT ",
1606        " OFFSET ",
1607        " RETURNING ",
1608    ] {
1609        pretty = pretty.replace(keyword, &format!("\n{}", keyword.trim_start()));
1610    }
1611    pretty.replace(" AND ", "\n  AND ")
1612}
1613
1614#[cfg(test)]
1615mod sql_log_option_tests {
1616    use super::*;
1617
1618    #[test]
1619    fn disabled_sql_log_rejects_executor_metadata_before_recording() {
1620        let mut context = UserContext::default();
1621        context.disable_sql_log();
1622        let now = SystemTime::now();
1623        context.record_metadata_log(&teaql_data_service::ExecutionMetadata {
1624            backend: "sql".to_owned(),
1625            operation: teaql_data_service::DataServiceOperation::Query,
1626            started_at: now,
1627            ended_at: now,
1628            affected_rows: None,
1629            result_count: Some(1),
1630            trace_chain: Vec::new(),
1631            comment: Some("disabled log test".to_owned()),
1632            backend_request_id: None,
1633            parameterized_query: Some("SELECT id FROM sample WHERE id = $1".to_owned()),
1634            params: vec![Value::I64(1)],
1635            debug_query: Some("SELECT id FROM sample WHERE id = 1".to_owned()),
1636        });
1637        assert!(context.sql_logs().is_empty());
1638    }
1639}
1640
1641#[cfg(test)]
1642mod entity_runtime_state_tests {
1643    use super::*;
1644    use crate::EntityKey;
1645
1646    #[test]
1647    fn reused_user_context_returns_independent_mutation_ledgers() {
1648        let context = UserContext::default();
1649        let first = context.entity_runtime_state();
1650        let key = EntityKey::new("School", 1_u64);
1651        first.set(key.clone(), "name", "First");
1652
1653        let second = context.entity_runtime_state();
1654
1655        assert_eq!(first.changed_field_names(&key).len(), 1);
1656        assert!(second.changed_field_names(&key).is_empty());
1657    }
1658}