Skip to main content

reddb_server/runtime/
statement_frame.rs

1use std::cell::RefCell;
2use std::collections::HashSet;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use super::execution_context::capture_current_snapshot;
7use super::impl_core::{
8    collections_referenced, current_auth_identity, current_connection_id, current_tenant,
9    has_with_prefix, intent_lock_modes_for, peek_top_level_as_of_with_table,
10    query_has_volatile_builtin, query_is_ask_statement, ConfigSnapshotGuard, CurrentSnapshotGuard,
11    KvStoreGuard, SecretStoreGuard, SnapshotContext, TxLocalTenantGuard,
12};
13use super::{RedDBRuntime, RuntimeQueryResult, RuntimeResultCacheEntry};
14use crate::api::{RedDBError, RedDBResult};
15use crate::auth::Role;
16use crate::storage::transaction::snapshot::{Snapshot, Xid};
17use reddb_rql::ast::QueryExpr;
18use reddb_rql::modes::{detect_mode, parse_multi, QueryMode};
19
20/// Coarse privilege classification for a statement, computed once at
21/// frame-build time from the SQL text. Mirrors the three-role auth
22/// model (`Role::Read < Role::Write < Role::Admin`) so the frame can
23/// answer "can this identity run this statement?" without re-walking
24/// the parsed `QueryExpr` at every call site.
25///
26/// `None` means the statement does not touch the privilege gate at
27/// all (transaction control, SET, SHOW). Such statements must remain
28/// runnable under any authenticated identity.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum Privilege {
31    /// Read-only data access (SELECT, EXPLAIN, SHOW). Satisfied by
32    /// any role from `Role::Read` upward.
33    Read,
34    /// Mutation of user data or schema author DDL (INSERT, UPDATE,
35    /// DELETE, CREATE/ALTER/DROP TABLE, CREATE MIGRATION). Requires
36    /// at least `Role::Write`.
37    Write,
38    /// Authority statements — GRANT, REVOKE, ALTER USER, APPLY /
39    /// ROLLBACK MIGRATION, IAM policy mutation. Requires `Role::Admin`.
40    Admin,
41    /// Statement does not consult the privilege gate (BEGIN, COMMIT,
42    /// ROLLBACK, SET, SHOW with no data exposure). Always permitted
43    /// for any authenticated identity.
44    None,
45}
46
47impl Privilege {
48    /// `true` iff `role` is sufficient to execute a statement carrying
49    /// this required privilege. Encodes the standard `Read ⊆ Write ⊆
50    /// Admin` containment used by the auth fallback path.
51    pub(crate) fn is_satisfied_by(self, role: Role) -> bool {
52        match self {
53            Self::None => true,
54            Self::Read => role.can_read(),
55            Self::Write => role.can_write(),
56            Self::Admin => role.can_admin(),
57        }
58    }
59}
60
61/// Coarse lock intent for a statement, computed once at frame-build
62/// time. Maps onto the storage-layer's `LockMode` matrix downstream
63/// but stays decoupled here so the runtime can answer "does this
64/// statement need the lock manager at all?" without a `use storage::`
65/// at every call site.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) enum LockIntent {
68    /// No collection-level lock needed (transaction control, SET,
69    /// SHOW, EXPLAIN). The lock-acquisition path can short-circuit.
70    None,
71    /// Reader-style intent: SELECT, joins, graph / queue / search
72    /// reads. Maps to `(IS, IS)` at the storage layer.
73    Shared,
74    /// Writer- or DDL-style intent: INSERT/UPDATE/DELETE (`(IX, IX)`)
75    /// and CREATE/ALTER/DROP (`(IX, X)`). Both are surfaced as
76    /// `Exclusive` at this granularity — call sites that need the
77    /// finer distinction still consult `intent_lock_modes_for`.
78    Exclusive,
79}
80
81/// Small, stable Interface that *represents* a read statement's
82/// execution context. Every read caller that needs to know "under
83/// what scope / identity / snapshot am I running, and is there an
84/// AS OF floor in effect?" consults this trait — never the
85/// underlying thread-locals or runtime fields directly.
86///
87/// The deletion test: removing this trait would force the four
88/// concerns it exposes back into ad-hoc lookups at every read
89/// callsite (`current_tenant()`, `current_auth_identity()`,
90/// `capture_current_snapshot()`, AS OF re-parsing). The trait
91/// concentrates them in one place so future changes (per-statement
92/// logging, audit, scope policy) have a single seam to extend.
93pub(crate) trait ReadFrame {
94    /// Effective tenant scope for the statement after WITHIN /
95    /// SET LOCAL TENANT / SET TENANT resolution. `None` means
96    /// "no tenant bound" (RLS deny-default applies).
97    fn effective_scope(&self) -> Option<&str>;
98
99    /// Authenticated identity observed at frame-build time, if any.
100    /// Returns `(username, role)` so callers can render audit lines
101    /// or feed RLS policy lookups without re-reading thread-locals.
102    fn identity(&self) -> Option<(&str, Role)>;
103
104    /// MVCC snapshot the statement reads against. For autocommit
105    /// this is a fresh snapshot; inside an active transaction it
106    /// is the txn's snapshot; under AS OF it is the resolved
107    /// historical xid.
108    fn snapshot(&self) -> &Snapshot;
109
110    /// Complete visibility context, including the reader's savepoint writes.
111    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
112        SnapshotContext {
113            snapshot: self.snapshot().clone(),
114            manager: runtime.inner.transaction_state.snapshot_manager(),
115            own_xids: HashSet::new(),
116            requires_index_fallback: true,
117            serializable_reader: None,
118        }
119    }
120
121    /// AS OF xid floor when AS OF was applied for this statement,
122    /// `None` for live reads. Useful for downstream callers that
123    /// want to gate behaviour on historical-read mode without
124    /// re-parsing the query.
125    fn as_of_floor(&self) -> Option<Xid>;
126
127    /// Stable result-cache key for the statement (already mixes
128    /// effective tenant + identity).
129    fn cache_key(&self) -> &str;
130
131    /// Whether the statement is safe to serve from / populate the
132    /// result cache. Combines two underlying signals:
133    ///
134    ///   * the query does not call a volatile builtin (e.g. `NOW()`,
135    ///     `RANDOM()`, `UUID()`), which would change between calls,
136    ///   * the connection is not inside an active transaction with
137    ///     uncommitted writes that other readers shouldn't observe.
138    ///
139    /// SELECT cache callsites (read + write) consult this method
140    /// instead of re-deriving safety from globals or poking the
141    /// frame's private fields. Removing it would force every cache
142    /// callsite to re-run `query_has_volatile_builtin` plus
143    /// `result_cache_safe(conn_id)` inline.
144    fn should_cache_result(&self) -> bool;
145
146    /// Coarse privilege class the statement requires, computed once
147    /// at frame-build time from the SQL prefix. Read/write dispatch
148    /// sites consult this instead of re-classifying the parsed
149    /// `QueryExpr` inline at every callsite.
150    ///
151    /// Removing this method would force every privilege gate to
152    /// recompute the (action, resource) classification from the
153    /// parsed expression and re-check the role hierarchy inline.
154    fn required_privilege(&self) -> Privilege;
155
156    /// Coarse collection-level lock intent the statement implies.
157    /// `None` lets the lock-acquisition path short-circuit without
158    /// touching the lock manager.
159    ///
160    /// Removing this method would force the lock-acquisition path
161    /// to always invoke `intent_lock_modes_for` (which itself walks
162    /// the parsed expression) even for transaction-control / SET /
163    /// SHOW statements that need no collection lock at all.
164    fn lock_intent(&self) -> LockIntent;
165
166    /// Set of collection ids the calling identity is allowed to
167    /// observe under the active `(tenant, role)` scope. Computed once
168    /// at frame-build time via the `AuthStore` visible-collections
169    /// cache (see `auth::scope_cache`) and used by `AuthorizedSearch`
170    /// to pre-filter SEARCH SIMILAR / SEARCH CONTEXT candidate sets
171    /// before any similarity score is computed (issue #119).
172    ///
173    /// `None` means the frame was built without an auth store wired —
174    /// embedded / single-tenant tests run that way. AI search call
175    /// sites refuse to proceed with `None`, which is the deny-default
176    /// the issue requires; pure SELECT paths fall back to the existing
177    /// per-row RLS gate.
178    fn visible_collections(&self) -> Option<&std::collections::HashSet<String>>;
179}
180
181/// Cheap first-word classification of a SQL statement, used at
182/// frame-build time to derive `Privilege` + `LockIntent` without
183/// re-parsing the query. Matches the keywords that the legacy
184/// inline checks in `RedDBRuntime::check_query_privilege` and
185/// `intent_lock_modes_for` already key on.
186/// Whether [`statement_kind`] reads `query` as a mutating statement.
187/// The Request seam uses it to keep its pre-write commit-policy check off
188/// read paths.
189pub(crate) fn statement_is_write(query: &str) -> bool {
190    statement_kind(query) == "write"
191}
192
193fn statement_kind(query: &str) -> &'static str {
194    let trimmed = query.trim_start();
195    // Skip a leading line / block comment so the classifier doesn't
196    // misread `/* ... */ SELECT ...` as an unknown statement.
197    let trimmed = if let Some(rest) = trimmed.strip_prefix("--") {
198        rest.split_once('\n')
199            .map(|(_, r)| r)
200            .unwrap_or("")
201            .trim_start()
202    } else {
203        trimmed
204    };
205    let mut tokens = trimmed.split(|c: char| c.is_whitespace() || c == '(' || c == ';');
206    let first = tokens.next().unwrap_or("");
207    let second = tokens.next().unwrap_or("");
208    if first.eq_ignore_ascii_case("KV") {
209        if second.eq_ignore_ascii_case("GET")
210            || second.eq_ignore_ascii_case("LIST")
211            || second.eq_ignore_ascii_case("WATCH")
212        {
213            return "read";
214        }
215        return "write";
216    }
217    if first.eq_ignore_ascii_case("VAULT") {
218        if second.eq_ignore_ascii_case("LIST")
219            || second.eq_ignore_ascii_case("WATCH")
220            || second.eq_ignore_ascii_case("HISTORY")
221        {
222            return "read";
223        }
224        return "write";
225    }
226    // ASCII-uppercase compare without allocating: SQL keywords are ASCII.
227    let mut buf = [0u8; 16];
228    let bytes = first.as_bytes();
229    let n = bytes.len().min(buf.len());
230    for i in 0..n {
231        buf[i] = bytes[i].to_ascii_uppercase();
232    }
233    match &buf[..n] {
234        b"SELECT" | b"WITH" | b"SHOW" | b"EXPLAIN" | b"DESCRIBE" | b"DESC" | b"RANK"
235        | b"APPROX" | b"APPROXIMATE" | b"ZRANK" | b"ZRANGE" | b"LIST" | b"WATCH" | b"GET"
236        | b"HISTORY" => "read",
237        b"INSERT" | b"UPDATE" | b"DELETE" | b"UPSERT" | b"MERGE" | b"COPY" | b"TRUNCATE" => "write",
238        b"CREATE" | b"ALTER" | b"DROP" | b"PROMOTE" | b"REINDEX" | b"VACUUM" | b"ANALYZE" => "ddl",
239        b"GRANT" | b"REVOKE" => "admin",
240        b"BEGIN" | b"START" | b"COMMIT" | b"ROLLBACK" | b"SAVEPOINT" | b"RELEASE" | b"END"
241        | b"SET" | b"RESET" | b"PREPARE" | b"EXECUTE" | b"DEALLOCATE" | b"USE" => "control",
242        b"PUT" | b"INCR" | b"DECR" | b"ADD" | b"ROTATE" | b"PURGE" | b"UNSEAL" | b"INVALIDATE" => {
243            "write"
244        }
245        _ => "unknown",
246    }
247}
248
249/// Refuse a pre-parsed expression that carries a top-level `AS OF`.
250///
251/// `AS OF` is resolved from the statement text (`AsOfSpec` -> commit /
252/// branch / tag / timestamp -> xid) and `TableQuery::as_of` is consumed
253/// nowhere in the runtime or scan path. An expression-only frame
254/// therefore cannot honour it, and installing a live snapshot for a
255/// historical read would answer with current rows presented as history.
256/// Fail loudly instead; callers that have the text (`execute_query`,
257/// `execute_prepared_query`, `execute_query_with_params`) resolve it
258/// correctly. Mirrors `peek_top_level_as_of_with_table`, which likewise
259/// only recognises a top-level `AS OF`.
260fn reject_unresolvable_as_of(expr: &QueryExpr) -> RedDBResult<()> {
261    let QueryExpr::Table(table) = expr else {
262        return Ok(());
263    };
264    if table.as_of.is_none() {
265        return Ok(());
266    }
267    Err(RedDBError::InvalidOperation(
268        "AS OF cannot be resolved from a pre-parsed expression — \
269         execute the statement text (execute_query / execute_prepared_query / \
270         execute_query_with_params) so the historical snapshot is resolved"
271            .to_string(),
272    ))
273}
274
275fn classify_privilege(query: &str) -> Privilege {
276    match statement_kind(query) {
277        "read" => Privilege::Read,
278        "write" => Privilege::Write,
279        // DDL is gated at `Role::Write` in the legacy fallback (see
280        // `RedDBRuntime::check_query_privilege` for CreateTable et al.),
281        // so it classifies as Write here. APPLY / ROLLBACK MIGRATION and
282        // GRANT / REVOKE upgrade to Admin via finer checks at the call
283        // site — the frame surfaces only the coarse class.
284        "ddl" => Privilege::Write,
285        "admin" => Privilege::Admin,
286        _ => Privilege::None,
287    }
288}
289
290fn classify_lock_intent(query: &str) -> LockIntent {
291    match statement_kind(query) {
292        "read" => LockIntent::Shared,
293        "write" | "ddl" => LockIntent::Exclusive,
294        _ => LockIntent::None,
295    }
296}
297
298pub(super) struct StatementExecutionFrame {
299    tx_local_tenant: Option<Option<String>>,
300    snapshot: Snapshot,
301    own_xids: HashSet<Xid>,
302    serializable_reader: Option<Xid>,
303    cache_key: String,
304    is_volatile_query: bool,
305    cache_safe: bool,
306    /// Effective tenant captured at frame-build time after WITHIN /
307    /// SET LOCAL TENANT / SET TENANT resolution. Stored on the frame
308    /// so the `ReadFrame` Interface can return a borrow without
309    /// re-touching the thread-local stack.
310    effective_scope: Option<String>,
311    /// Auth identity captured at frame-build time. `None` for
312    /// embedded / anonymous callers.
313    identity: Option<(String, Role)>,
314    /// `Some(xid)` when AS OF resolved to a historical xid; `None`
315    /// for live reads.
316    as_of_floor: Option<Xid>,
317    /// True when the statement snapshot can require tuple versions that
318    /// current secondary indexes no longer contain.
319    requires_index_fallback: bool,
320    /// Privilege class required by the statement, derived from the
321    /// SQL text at frame-build time. Read/write dispatch sites
322    /// consult this instead of re-classifying the parsed expression.
323    required_privilege: Privilege,
324    /// Collection-level lock intent the statement implies. The
325    /// lock-acquisition path short-circuits when this is `None`.
326    lock_intent: LockIntent,
327    /// Set of collection ids the active `(tenant, role)` scope is
328    /// allowed to observe. Computed at frame-build time via the
329    /// `AuthStore` visibility cache and consumed by `AuthorizedSearch`
330    /// to gate SEARCH SIMILAR / SEARCH CONTEXT candidate sets before
331    /// scoring (issue #119). `None` when no auth store is wired
332    /// (embedded test mode) — AI search refuses on `None`.
333    visible_collections: Option<HashSet<String>>,
334    /// Per-owner buffer arena for query-result row chunks (#885). Owned
335    /// by the frame because the frame already owns the query lifecycle;
336    /// lent to the row-streaming path (`execute_runtime_table_query_in`)
337    /// so chunk buffers are reused across the statement's chunk-fetches
338    /// instead of allocated fresh per chunk. Reclaimed when the frame
339    /// drops at statement end — no `thread_local!` scratch, which would
340    /// be unsound under tokio's work-stealing runtime.
341    row_arena: Rc<RefCell<super::query_exec::RowBufferArena>>,
342}
343
344pub(super) struct StatementFrameGuards {
345    _tx_local_guard: TxLocalTenantGuard,
346    _config_snapshot_guard: ConfigSnapshotGuard,
347    _secret_store_guard: SecretStoreGuard,
348    _kv_store_guard: KvStoreGuard,
349    _snapshot_guard: CurrentSnapshotGuard,
350}
351
352pub(super) struct PreparedStatement {
353    pub(super) expr: QueryExpr,
354    pub(super) mode: QueryMode,
355}
356
357/// What a `StatementExecutionFrame` is built from.
358///
359/// The frame derives the statement snapshot (including any `AS OF`
360/// floor), the coarse privilege class, the lock intent, the volatility
361/// flag, and the result-cache key from its input. Feeding it anything
362/// other than the statement itself makes every one of those describe a
363/// different statement than the one that runs, so the input is named
364/// explicitly here rather than being "some string".
365#[derive(Clone, Copy)]
366pub(super) enum StatementIdentity<'a> {
367    /// The statement's SQL text. All five derivations are exact.
368    Text(&'a str),
369    /// A pre-parsed expression with no accompanying text — the nested
370    /// in-statement callers (CTE prelude, materialized-view refresh,
371    /// inline graph TVF) hold an expression the outer statement already
372    /// parsed.
373    ///
374    /// Text-derived facts are not guessed from a kind label: there is no
375    /// stable cache key (a label would collide across every statement of
376    /// the same kind), and the coarse privilege / lock short-circuits are
377    /// left at `None` so the deep grant engine stays the gate. `AS OF` is
378    /// rejected up front by `build` because it cannot be resolved without
379    /// the text.
380    Expr(&'a QueryExpr),
381}
382
383impl<'a> StatementIdentity<'a> {
384    /// SQL text when the frame was built from text, `""` otherwise. Used
385    /// for the text-derived classifications, all of which degrade to the
386    /// deliberately inert `unknown` / `None` classes on the expr path.
387    fn text(&self) -> &'a str {
388        match self {
389            Self::Text(query) => query,
390            Self::Expr(_) => "",
391        }
392    }
393}
394
395impl StatementExecutionFrame {
396    pub(super) fn build(
397        runtime: &RedDBRuntime,
398        identity_source: StatementIdentity<'_>,
399    ) -> RedDBResult<Self> {
400        let query = identity_source.text();
401        if let StatementIdentity::Expr(expr) = identity_source {
402            reject_unresolvable_as_of(expr)?;
403        }
404        let conn_id = current_connection_id();
405        let tx_local_tenant = runtime.inner.transaction_state.local_tenant(conn_id);
406        let tx_context = runtime.inner.transaction_state.context(conn_id);
407        let own_xids = own_transaction_xids(tx_context.as_ref());
408        let serializable_reader = tx_context
409            .as_ref()
410            .filter(|ctx| {
411                ctx.isolation == crate::storage::transaction::IsolationLevel::Serializable
412            })
413            .map(|ctx| ctx.xid);
414        let (snapshot, as_of_floor) = runtime.statement_snapshot(query, identity_source)?;
415        let requires_index_fallback = as_of_floor.is_some() || tx_context.is_some();
416        let (cache_key, is_volatile_query) = match identity_source {
417            StatementIdentity::Text(_) => (
418                result_cache_key(query),
419                query_has_volatile_builtin(query) || query_is_ask_statement(query),
420            ),
421            // No text means no stable key, so the frame opts out of the
422            // result cache on both sides: an empty key is never read from
423            // or written to, and `is_volatile_query` is set so
424            // `should_cache_result` refuses regardless of the key.
425            StatementIdentity::Expr(_) => (String::new(), true),
426        };
427        let cache_safe = runtime.result_cache_safe(conn_id);
428        // Capture identity + effective scope under the same
429        // thread-local view that the cache key was built from, so
430        // the Interface and the cache key agree on what "this
431        // statement" means.
432        let effective_scope = current_tenant();
433        let identity = current_auth_identity();
434
435        // Coarse classification of the statement, computed once from
436        // the SQL prefix so downstream callers don't re-derive it
437        // from the parsed `QueryExpr` at every privilege / lock site.
438        // On the expr path `query` is empty, so both classify as the
439        // inert `None` class: the coarse gate can only deny, never
440        // allow (see `check_query_privilege`), so declining to
441        // short-circuit leaves the deep grant engine as the gate.
442        let required_privilege = classify_privilege(query);
443        let lock_intent = classify_lock_intent(query);
444
445        // Issue #119: resolve the visible-collections set for the
446        // active (tenant, role) scope. Only meaningful when an auth
447        // store is wired *and* an identity was captured — embedded
448        // anonymous callers fall back to `None`, and AI search call
449        // sites refuse on `None`.
450        let visible_collections = match (runtime.inner.auth_store.read().clone(), identity.as_ref())
451        {
452            (Some(store), Some((principal, role))) => {
453                let collections = runtime.inner.db.store().list_collections();
454                Some(store.visible_collections_for_scope(
455                    effective_scope.as_deref(),
456                    *role,
457                    principal,
458                    &collections,
459                ))
460            }
461            _ => None,
462        };
463
464        Ok(Self {
465            tx_local_tenant,
466            snapshot,
467            own_xids,
468            serializable_reader,
469            cache_key,
470            is_volatile_query,
471            cache_safe,
472            effective_scope,
473            identity,
474            as_of_floor,
475            requires_index_fallback,
476            required_privilege,
477            lock_intent,
478            visible_collections,
479            row_arena: Rc::new(RefCell::new(super::query_exec::RowBufferArena::new())),
480        })
481    }
482
483    /// Lend the frame's per-owner row-buffer arena (#885) to the
484    /// row-streaming path. Returns a cloned `Rc` handle; the frame remains
485    /// the owner and the arena is reclaimed when the frame drops at
486    /// statement end.
487    pub(super) fn row_arena(&self) -> Rc<RefCell<super::query_exec::RowBufferArena>> {
488        Rc::clone(&self.row_arena)
489    }
490
491    pub(super) fn install(&self, runtime: &RedDBRuntime) -> StatementFrameGuards {
492        StatementFrameGuards {
493            _tx_local_guard: TxLocalTenantGuard::install(self.tx_local_tenant.clone()),
494            _config_snapshot_guard: ConfigSnapshotGuard::install(
495                Arc::clone(&runtime.inner.db),
496                runtime.inner.auth_store.read().clone(),
497            ),
498            _secret_store_guard: SecretStoreGuard::install(runtime.inner.auth_store.read().clone()),
499            _kv_store_guard: KvStoreGuard::install(runtime.inner.auth_store.read().clone()),
500            _snapshot_guard: CurrentSnapshotGuard::install(SnapshotContext {
501                snapshot: self.snapshot.clone(),
502                manager: runtime.inner.transaction_state.snapshot_manager(),
503                own_xids: self.own_xids.clone(),
504                requires_index_fallback: self.requires_index_fallback,
505                serializable_reader: self.serializable_reader,
506            }),
507        }
508    }
509
510    pub(super) fn cache_key(&self) -> &str {
511        &self.cache_key
512    }
513
514    pub(super) fn can_read_result_cache(&self) -> bool {
515        // Delegates to the `ReadFrame` Interface so the volatile +
516        // active-tx safety decision lives in exactly one place.
517        <Self as ReadFrame>::should_cache_result(self)
518    }
519
520    pub(super) fn should_write_result_cache(&self, result: &RuntimeQueryResult) -> bool {
521        // Cache-safety (volatile builtin, active-tx writes) comes from
522        // the Interface; the rest are write-side payload heuristics
523        // (statement shape, result size) that aren't part of the
524        // safety contract.
525        <Self as ReadFrame>::should_cache_result(self)
526            && result.statement_type == "select"
527            && result.engine != "vault"
528            && result.engine != "runtime-rank"
529            // `QUEUE READ` is a stateful read: a delayed message
530            // (issue #722) becomes deliverable over time without a
531            // producer push to invalidate the cache, so a cached empty
532            // result would hide it. Skip caching entirely.
533            && result.statement != "queue_group_read"
534            // SCRUB is a stateful read too: the background form advances
535            // a pacing cursor per tick (a cached tick would replay the
536            // summary and never advance), and even a full pass must
537            // re-read the file to see new corruption. Never cache.
538            && result.engine != "runtime-scrub"
539            && result.result.pre_serialized_json.is_none()
540            // Graph-analytics TVF output (issue #802) is deterministic and
541            // expensive to recompute, so it is cached at any row count. The
542            // ≤5-row heuristic only bounds payload size for ordinary SELECTs.
543            && (is_graph_tvf_engine(result.engine) || result.result.records.len() <= 5)
544    }
545
546    pub(super) fn read_result_cache(&self, runtime: &RedDBRuntime) -> Option<RuntimeQueryResult> {
547        if self.can_read_result_cache() {
548            runtime
549                .get_result_cache_entry(self.cache_key())
550                .map(|mut result| {
551                    if let Some(vector) = result.result.stats.vector.as_mut() {
552                        vector.cache_hit = true;
553                    }
554                    result
555                })
556        } else {
557            None
558        }
559    }
560
561    pub(super) fn write_result_cache(
562        &self,
563        runtime: &RedDBRuntime,
564        result: &RuntimeQueryResult,
565        scopes: HashSet<String>,
566    ) {
567        if self.should_write_result_cache(result) {
568            runtime.put_result_cache_entry(
569                self.cache_key(),
570                RuntimeResultCacheEntry {
571                    result: result.clone(),
572                    cached_at: std::time::Instant::now(),
573                    scopes,
574                },
575            );
576        }
577    }
578
579    pub(super) fn prepare_cte(&self, query: &str) -> RedDBResult<Option<QueryExpr>> {
580        // Detected via cheap prefix check so non-CTE queries skip the
581        // full parse here. CTE-bearing queries bypass the plan cache
582        // and result cache (rare workload — perf optimization is a
583        // follow-up). Inlining substitutes every CTE reference with
584        // its body as a subquery in FROM, after which the existing
585        // subquery-in-FROM machinery handles execution. Recursive
586        // CTEs are rejected explicitly until fixpoint execution wires
587        // through the runtime.
588        if !has_with_prefix(query) {
589            return Ok(None);
590        }
591        let parsed =
592            reddb_rql::parser::parse(query).map_err(|err| RedDBError::Query(err.to_string()))?;
593        if parsed.with_clause.is_some() {
594            let rewritten = crate::storage::query::executors::inline_ctes(parsed)
595                .map_err(|err| RedDBError::Query(err.to_string()))?;
596            return Ok(Some(rewritten));
597        }
598        // No WITH after parse (the prefix matched something else like
599        // `WITHIN` that already routed elsewhere) — fall through to
600        // the normal path with the original query.
601        Ok(None)
602    }
603
604    pub(super) fn prepare_statement(
605        &self,
606        runtime: &RedDBRuntime,
607        query: &str,
608    ) -> RedDBResult<PreparedStatement> {
609        let mode = detect_mode(query);
610        if matches!(mode, QueryMode::Unknown) {
611            return Err(RedDBError::Query("unable to detect query mode".to_string()));
612        }
613
614        // ── Plan cache: reuse only exact-query ASTs ──
615        //
616        // DML statements (INSERT/UPDATE/DELETE) almost always have unique literal
617        // values, so caching them burns CPU on eviction bookkeeping (Vec::remove(0)
618        // shifts the entire LRU list) with zero hit rate. Skip the cache entirely
619        // Plan cache applies to statements whose shape can be
620        // normalised + rebound (`UPDATE t SET x=? WHERE _entity_id=?`
621        // reuses the same plan across thousands of varying literals).
622        // INSERT is still bypassed — its shape changes per column set
623        // and bulk paths don't go through here anyway.
624        let first_word = query
625            .trim()
626            .split_ascii_whitespace()
627            .next()
628            .unwrap_or("")
629            .to_ascii_uppercase();
630        let is_insert = first_word == "INSERT";
631        // #1370 — volatile queries ($config / $secret resolve mutable runtime
632        // state at execution time) must bypass the plan cache too. A cached
633        // optimized plan drops the live `$config` resolution, so a later
634        // `SET CONFIG` would be ignored and the query would serve a stale value.
635        // Re-parse fresh every time so the resolver runs against current state.
636        let bypass_plan_cache = is_insert || self.is_volatile_query;
637
638        // Fused normalize+extract: one byte-scan produces both the
639        // cache_key AND the literal bindings. Saves a second Lexer
640        // pass over the query text on every cache hit — dominant
641        // cost on tight UPDATE loops that hit the same shape
642        // thousands of times with varying literals.
643        let (cache_key, prescan_binds) = if bypass_plan_cache {
644            (String::new(), Vec::new())
645        } else {
646            crate::storage::query::planner::cache_key::normalize_and_extract(query)
647        };
648
649        let expr = if bypass_plan_cache {
650            // Bypass plan cache for INSERT — shape varies per query.
651            parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
652        } else {
653            // ── Hot path: read lock only (no writer serialization on cache hits) ──
654            //
655            // peek() is a non-mutating probe: no LRU promotion, no touch().
656            // This lets concurrent readers proceed without blocking each other.
657            // On hit we bind literals if needed and return immediately.
658            // Only on miss do we drop to a write lock to parse + insert.
659            let hit = {
660                let plan_cache = runtime.inner.query_cache.read();
661                plan_cache.peek(&cache_key).map(|cached| {
662                    let parameter_count = cached.parameter_count;
663                    let optimized = cached.plan.optimized.clone();
664                    let exact_query = cached.exact_query.clone();
665                    (parameter_count, optimized, exact_query)
666                })
667            };
668
669            if let Some((parameter_count, optimized, exact_query)) = hit {
670                if parameter_count > 0 {
671                    // Shape hit: use the binds extracted during normalise.
672                    let shape_binds = prescan_binds.clone();
673                    if let Some(bound) =
674                        crate::storage::query::planner::shape::bind_parameterized_query(
675                            &optimized,
676                            &shape_binds,
677                            parameter_count,
678                        )
679                    {
680                        bound
681                    } else if exact_query.as_deref() == Some(query) {
682                        // Bind failed but exact query matches — use as-is.
683                        optimized
684                    } else {
685                        // Bind failed and literals differ: re-parse fresh.
686                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
687                    }
688                } else {
689                    // No parameters means either there truly are no literals,
690                    // or this statement type does not participate in shape
691                    // parameterization (for example graph/queue commands).
692                    // Reusing a normalized-cache hit across a different exact
693                    // query can therefore leak stale literals into execution.
694                    if exact_query.as_deref() == Some(query) {
695                        optimized
696                    } else {
697                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
698                    }
699                }
700            } else {
701                // Cache miss — parse, parameterize, store.
702                let parsed =
703                    parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
704                let (cached_expr, parameter_count) = if let Some(prepared) =
705                    crate::storage::query::planner::shape::parameterize_query_expr(&parsed)
706                {
707                    (prepared.shape, prepared.parameter_count)
708                } else {
709                    (parsed.clone(), 0)
710                };
711                {
712                    let mut pc = runtime.inner.query_cache.write();
713                    let plan = crate::storage::query::planner::QueryPlan::new(
714                        parsed.clone(),
715                        cached_expr,
716                        Default::default(),
717                    );
718                    pc.insert(
719                        cache_key.clone(),
720                        crate::storage::query::planner::CachedPlan::new(plan)
721                            .with_shape_key(cache_key.clone())
722                            .with_exact_query(query.to_string())
723                            .with_parameter_count(parameter_count),
724                    );
725                }
726                parsed
727            }
728        };
729
730        // Phase 5 PG parity: substitute any registered view name that
731        // appears in the expression with its stored body. Runs after
732        // parse and before dispatch so the SQL entrypoint gets the
733        // same view resolution `execute_query_expr` already does.
734        let expr = runtime.rewrite_view_refs(expr);
735
736        Ok(PreparedStatement { expr, mode })
737    }
738
739    pub(super) fn check_query_privilege(
740        &self,
741        runtime: &RedDBRuntime,
742        expr: &QueryExpr,
743    ) -> RedDBResult<()> {
744        // Frame-level coarse gate. We consult `required_privilege()`
745        // (computed once at frame-build) against the captured identity
746        // before the deep grant engine walks the parsed expression.
747        // The coarse gate cannot ALLOW anything the grant engine would
748        // deny — it only short-circuits the obvious "Role::Read tries
749        // INSERT" case so a downstream caller never has to redo this
750        // check inline. `Privilege::None` (transaction control / SET /
751        // SHOW) flows through unchanged; the grant engine treats those
752        // as bypass too.
753        if let Some((username, role)) = <Self as ReadFrame>::identity(self) {
754            let needed = <Self as ReadFrame>::required_privilege(self);
755            if !needed.is_satisfied_by(role) {
756                // Issue #205 — when the deep grant engine *also*
757                // denies, we treat this as an ordinary permission
758                // failure. But when an Admin-only statement reaches
759                // this gate without an auth_store wired (so the deep
760                // engine can't double-check), the coarse rejection is
761                // the only line of defence — emit an OperatorEvent so
762                // the operator notices an Admin-class statement was
763                // attempted with insufficient role.
764                if matches!(needed, Privilege::Admin) && runtime.inner.auth_store.read().is_none() {
765                    crate::telemetry::operator_event::OperatorEvent::AuthBypass {
766                        principal: username.to_string(),
767                        resource: format!("statement requiring {needed:?}"),
768                        detail: format!(
769                            "auth_store not wired; coarse gate is sole defence (role={role:?})"
770                        ),
771                    }
772                    .emit_global();
773                }
774                return Err(RedDBError::Query(format!(
775                    "permission denied: principal=`{username}` role=`{role:?}` lacks {needed:?} privilege"
776                )));
777            }
778        }
779        runtime
780            .check_query_privilege(expr)
781            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
782    }
783
784    pub(super) fn prepare_dispatch(
785        &self,
786        runtime: &RedDBRuntime,
787        expr: &QueryExpr,
788    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
789        runtime.validate_model_operations_before_auth(expr)?;
790        self.check_query_privilege(runtime, expr)?;
791        Ok(self.acquire_intent_locks(runtime, expr))
792    }
793
794    pub(super) fn acquire_intent_locks(
795        &self,
796        runtime: &RedDBRuntime,
797        expr: &QueryExpr,
798    ) -> Option<crate::runtime::locking::LockerGuard> {
799        if !runtime.config_bool("concurrency.locking.enabled", true) {
800            return None;
801        }
802        // Frame-level short-circuit: if the statement carries no lock
803        // intent (transaction control, SET, SHOW), skip the lock
804        // manager entirely instead of letting `intent_lock_modes_for`
805        // walk the parsed expression to reach the same conclusion.
806        if matches!(<Self as ReadFrame>::lock_intent(self), LockIntent::None) {
807            return None;
808        }
809        intent_lock_modes_for(expr).map(|(global_mode, coll_mode)| {
810            let mut guard =
811                crate::runtime::locking::LockerGuard::new(runtime.inner.lock_manager.clone());
812            let _ = guard.acquire(crate::runtime::locking::Resource::Global, global_mode);
813            for collection in collections_referenced(expr) {
814                let _ = guard.acquire(
815                    crate::runtime::locking::Resource::Collection(collection),
816                    coll_mode,
817                );
818            }
819            guard
820        })
821    }
822}
823
824impl ReadFrame for StatementExecutionFrame {
825    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
826        SnapshotContext {
827            snapshot: self.snapshot.clone(),
828            manager: runtime.inner.transaction_state.snapshot_manager(),
829            own_xids: self.own_xids.clone(),
830            requires_index_fallback: self.requires_index_fallback,
831            serializable_reader: self.serializable_reader,
832        }
833    }
834
835    fn effective_scope(&self) -> Option<&str> {
836        self.effective_scope.as_deref()
837    }
838
839    fn identity(&self) -> Option<(&str, Role)> {
840        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
841    }
842
843    fn snapshot(&self) -> &Snapshot {
844        &self.snapshot
845    }
846
847    fn as_of_floor(&self) -> Option<Xid> {
848        self.as_of_floor
849    }
850
851    fn cache_key(&self) -> &str {
852        &self.cache_key
853    }
854
855    fn should_cache_result(&self) -> bool {
856        !self.is_volatile_query && self.cache_safe
857    }
858
859    fn required_privilege(&self) -> Privilege {
860        self.required_privilege
861    }
862
863    fn lock_intent(&self) -> LockIntent {
864        self.lock_intent
865    }
866
867    fn visible_collections(&self) -> Option<&HashSet<String>> {
868        self.visible_collections.as_ref()
869    }
870}
871
872/// Lightweight `ReadFrame` carrier used by AI command entry points
873/// (`SEARCH SIMILAR`, `SEARCH CONTEXT`, `ASK`).
874///
875/// Issue #119 calls this struct `EffectiveScope`. It bundles the
876/// `(tenant, identity, role, visible_collections, snapshot)` tuple so
877/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
878/// instead of re-reading thread-locals at every call site.
879///
880/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
881/// from the per-statement thread-locals (identical to how
882/// `StatementExecutionFrame::build` derives them) and resolves
883/// `visible_collections` via the `AuthStore` cache.
884pub struct EffectiveScope {
885    pub(crate) captured_snapshot: Option<SnapshotContext>,
886    pub(crate) tenant: Option<String>,
887    pub(crate) identity: Option<(String, Role)>,
888    pub(crate) snapshot: Snapshot,
889    pub(crate) visible_collections: Option<HashSet<String>>,
890}
891
892impl EffectiveScope {
893    /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
894    /// to gate LLM-backed NER calls behind `ai:ner:read`.
895    ///
896    /// Placeholder for now: always returns `false`. The auth engine's
897    /// capability matrix is future work; until it lands, every routed
898    /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
899    /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
900    /// Documented in code so the wire-up is a one-line change once
901    /// the auth engine learns capabilities.
902    pub fn has_capability(&self, _capability: &str) -> bool {
903        false
904    }
905}
906
907impl ReadFrame for EffectiveScope {
908    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
909        self.captured_snapshot
910            .clone()
911            .unwrap_or_else(|| SnapshotContext {
912                snapshot: self.snapshot.clone(),
913                manager: runtime.inner.transaction_state.snapshot_manager(),
914                own_xids: HashSet::new(),
915                requires_index_fallback: true,
916                serializable_reader: None,
917            })
918    }
919
920    fn effective_scope(&self) -> Option<&str> {
921        self.tenant.as_deref()
922    }
923    fn identity(&self) -> Option<(&str, Role)> {
924        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
925    }
926    fn snapshot(&self) -> &Snapshot {
927        &self.snapshot
928    }
929    fn as_of_floor(&self) -> Option<Xid> {
930        None
931    }
932    fn cache_key(&self) -> &str {
933        ""
934    }
935    fn should_cache_result(&self) -> bool {
936        false
937    }
938    fn required_privilege(&self) -> Privilege {
939        Privilege::Read
940    }
941    fn lock_intent(&self) -> LockIntent {
942        LockIntent::Shared
943    }
944    fn visible_collections(&self) -> Option<&HashSet<String>> {
945        self.visible_collections.as_ref()
946    }
947}
948
949fn own_transaction_xids(
950    ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
951) -> HashSet<Xid> {
952    let mut set = HashSet::new();
953    if let Some(ctx) = ctx {
954        set.insert(ctx.xid);
955        for (_, sub) in &ctx.savepoints {
956            set.insert(*sub);
957        }
958        for sub in &ctx.released_sub_xids {
959            set.insert(*sub);
960        }
961    }
962    set
963}
964
965impl RedDBRuntime {
966    /// Build the AI command `EffectiveScope` from the current
967    /// statement thread-locals + auth store.
968    ///
969    /// Returns `None` for embedded callers (no auth store, no
970    /// identity) — `AuthorizedSearch` treats `None` as deny-default.
971    pub(crate) fn ai_scope(&self) -> EffectiveScope {
972        let tenant = super::impl_core::current_tenant();
973        let identity = super::impl_core::current_auth_identity();
974        let captured_snapshot = super::execution_context::capture_current_snapshot();
975        let snapshot = captured_snapshot.as_ref().map_or_else(
976            || self.current_snapshot(),
977            |context| context.snapshot.clone(),
978        );
979        let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
980            (Some(store), Some((principal, role))) => {
981                let collections = self.inner.db.store().list_collections();
982                Some(store.visible_collections_for_scope(
983                    tenant.as_deref(),
984                    *role,
985                    principal,
986                    &collections,
987                ))
988            }
989            _ => None,
990        };
991        EffectiveScope {
992            captured_snapshot,
993            tenant,
994            identity,
995            snapshot,
996            visible_collections,
997        }
998    }
999}
1000
1001/// Test fixtures for callers that need to drive `ReadFrame` without
1002/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
1003/// only leaks across module boundaries inside the crate.
1004#[cfg(test)]
1005pub(crate) mod test_support {
1006    use super::{LockIntent, Privilege, ReadFrame};
1007    use crate::auth::Role;
1008    use crate::storage::transaction::snapshot::{Snapshot, Xid};
1009    use std::collections::HashSet;
1010
1011    /// A `ReadFrame` impl with hand-set fields. Used by
1012    /// `authorized_search` tests to assert the deny-default and
1013    /// scope-trim behaviour without going through frame construction.
1014    pub(crate) struct FakeReadFrame {
1015        pub tenant: Option<String>,
1016        pub identity: Option<(String, Role)>,
1017        pub snapshot: Snapshot,
1018        pub visible: Option<HashSet<String>>,
1019    }
1020
1021    impl FakeReadFrame {
1022        pub(crate) fn without_scope() -> Self {
1023            Self {
1024                tenant: None,
1025                identity: None,
1026                snapshot: Snapshot {
1027                    xid: 0,
1028                    in_progress: HashSet::new(),
1029                },
1030                visible: None,
1031            }
1032        }
1033
1034        pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
1035            Self {
1036                tenant: Some("acme".to_string()),
1037                identity: Some(("alice".to_string(), Role::Read)),
1038                snapshot: Snapshot {
1039                    xid: 0,
1040                    in_progress: HashSet::new(),
1041                },
1042                visible: Some(visible),
1043            }
1044        }
1045    }
1046
1047    impl ReadFrame for FakeReadFrame {
1048        fn effective_scope(&self) -> Option<&str> {
1049            self.tenant.as_deref()
1050        }
1051        fn identity(&self) -> Option<(&str, Role)> {
1052            self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
1053        }
1054        fn snapshot(&self) -> &Snapshot {
1055            &self.snapshot
1056        }
1057        fn as_of_floor(&self) -> Option<Xid> {
1058            None
1059        }
1060        fn cache_key(&self) -> &str {
1061            ""
1062        }
1063        fn should_cache_result(&self) -> bool {
1064            false
1065        }
1066        fn required_privilege(&self) -> Privilege {
1067            Privilege::Read
1068        }
1069        fn lock_intent(&self) -> LockIntent {
1070            LockIntent::Shared
1071        }
1072        fn visible_collections(&self) -> Option<&HashSet<String>> {
1073            self.visible.as_ref()
1074        }
1075    }
1076}
1077
1078impl RedDBRuntime {
1079    /// Resolve the snapshot for the current statement, returning
1080    /// the snapshot itself and (when AS OF is in effect) the
1081    /// resolved xid floor. The floor is the same xid carried inside
1082    /// `Snapshot.xid` for AS OF reads — exposing it separately lets
1083    /// the `ReadFrame` Interface tell "live read" from "historical
1084    /// read" without inferring from `in_progress.is_empty()`.
1085    ///
1086    /// A live read nested inside an already-installed frame inherits that
1087    /// frame's snapshot instead of minting a fresh one — but only when it
1088    /// is genuinely *part of* that statement, which is exactly what
1089    /// [`StatementIdentity::Expr`] means. A CTE prelude, a
1090    /// materialized-view refresh, or an inline graph TVF is one statement
1091    /// and must observe one snapshot; re-minting under autocommit / Read
1092    /// Committed takes a new high-water mark, so the nodes and edges of a
1093    /// single graph TVF could otherwise be materialized under two
1094    /// different snapshots.
1095    ///
1096    /// A nested `Text` statement is a different thing entirely: a driver
1097    /// loop issuing statement after statement through `execute_query`.
1098    /// Those are sequential autocommit statements and each must see its
1099    /// predecessors' commits. Inheriting there froze the loop's view —
1100    /// `apply_batched` (`impl_migrations.rs`) drives `APPLY MIGRATION …
1101    /// BATCH N ROWS` by re-running the body with `LIMIT N` until fewer
1102    /// than `N` rows are affected, so a frozen snapshot re-selected the
1103    /// same rows forever and the migration never converged. Twenty-five
1104    /// rows at `BATCH 7` should finish in four iterations; it hung
1105    /// unbounded instead.
1106    ///
1107    /// `AS OF` still wins over both — it names its own snapshot explicitly.
1108    fn statement_snapshot(
1109        &self,
1110        query: &str,
1111        identity: StatementIdentity<'_>,
1112    ) -> RedDBResult<(Snapshot, Option<Xid>)> {
1113        match peek_top_level_as_of_with_table(query) {
1114            Some((spec, Some(table))) => {
1115                if !table.starts_with("red_") && !self.vcs_is_versioned(&table)? {
1116                    return Err(RedDBError::InvalidConfig(format!(
1117                        "AS OF requires a versioned collection — \
1118                         `{table}` has not opted in. \
1119                         Call vcs.set_versioned(\"{table}\", true) first."
1120                    )));
1121                }
1122                let xid = self.vcs_resolve_as_of(spec)?;
1123                Ok((
1124                    Snapshot {
1125                        xid,
1126                        in_progress: HashSet::new(),
1127                    },
1128                    Some(xid),
1129                ))
1130            }
1131            Some((spec, None)) => {
1132                let xid = self.vcs_resolve_as_of(spec)?;
1133                Ok((
1134                    Snapshot {
1135                        xid,
1136                        in_progress: HashSet::new(),
1137                    },
1138                    Some(xid),
1139                ))
1140            }
1141            None => match (identity, capture_current_snapshot()) {
1142                (StatementIdentity::Expr(_), Some(outer)) => Ok((outer.snapshot, None)),
1143                _ => Ok((self.current_snapshot(), None)),
1144            },
1145        }
1146    }
1147
1148    fn result_cache_safe(&self, conn_id: u64) -> bool {
1149        let has_active_xids = self
1150            .inner
1151            .transaction_state
1152            .snapshot_manager()
1153            .oldest_active_xid()
1154            .is_some();
1155        let in_own_tx = self.inner.transaction_state.in_transaction(conn_id);
1156        !has_active_xids && !in_own_tx
1157    }
1158}
1159
1160/// Whether a result's `engine` tag is one of the graph-analytics TVF
1161/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
1162/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
1163/// algorithm output that is cached regardless of row count.
1164fn is_graph_tvf_engine(engine: &str) -> bool {
1165    matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
1166}
1167
1168fn result_cache_key(query: &str) -> String {
1169    let tenant = current_tenant().unwrap_or_default();
1170    let auth = current_auth_identity()
1171        .map(|(user, role)| format!("{}|{:?}", user, role))
1172        .unwrap_or_default();
1173    if tenant.is_empty() && auth.is_empty() {
1174        query.to_string()
1175    } else {
1176        format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use crate::api::RedDBOptions;
1184    use crate::runtime::impl_core::{
1185        clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1186        set_current_tenant,
1187    };
1188    use crate::runtime::RedDBRuntime;
1189
1190    fn fresh_runtime() -> RedDBRuntime {
1191        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1192    }
1193
1194    /// Ensure thread-local state from a prior test can't leak into
1195    /// the next one — tests in the same binary share the thread.
1196    fn reset_thread_locals() {
1197        clear_current_tenant();
1198        clear_current_auth_identity();
1199    }
1200
1201    #[test]
1202    fn autocommit_select_takes_live_snapshot() {
1203        reset_thread_locals();
1204        let rt = fresh_runtime();
1205        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1206            .expect("frame builds for SELECT 1");
1207
1208        // The inclusive bound excludes the next unallocated writer xid.
1209        // A pristine manager therefore has a valid empty snapshot at zero.
1210        let f: &dyn ReadFrame = &frame;
1211        assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1212        assert_eq!(f.snapshot().xid, rt.snapshot_manager().peek_next_xid() - 1);
1213        assert!(!f.snapshot().sees(rt.snapshot_manager().peek_next_xid(), 0));
1214    }
1215
1216    #[test]
1217    fn frame_captures_identity_and_scope() {
1218        reset_thread_locals();
1219        set_current_tenant("acme".to_string());
1220        set_current_auth_identity("alice".to_string(), Role::Write);
1221
1222        let rt = fresh_runtime();
1223        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1224            .expect("frame builds");
1225        let f: &dyn ReadFrame = &frame;
1226
1227        assert_eq!(f.effective_scope(), Some("acme"));
1228        let id = f.identity().expect("identity captured");
1229        assert_eq!(id.0, "alice");
1230        assert!(matches!(id.1, Role::Write));
1231
1232        // Cache key mixes scope + identity so two callers under
1233        // different tenants never share a cache slot.
1234        assert!(
1235            f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1236            "cache key folds in scope + identity, got {:?}",
1237            f.cache_key()
1238        );
1239
1240        reset_thread_locals();
1241    }
1242
1243    #[test]
1244    fn as_of_rejects_non_versioned_user_collection() {
1245        reset_thread_locals();
1246        let rt = fresh_runtime();
1247
1248        // `not_versioned` is a plain user collection — the frame
1249        // builder must reject AS OF until the caller opts in via
1250        // `vcs.set_versioned`.
1251        let err = match StatementExecutionFrame::build(
1252            &rt,
1253            StatementIdentity::Text("SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'"),
1254        ) {
1255            Err(e) => e,
1256            Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1257        };
1258
1259        let msg = format!("{err}");
1260        assert!(
1261            msg.contains("AS OF requires a versioned collection"),
1262            "expected AS OF rejection, got: {msg}"
1263        );
1264    }
1265
1266    /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1267    ///
1268    /// Sets a tenant + identity via the public thread-local API the
1269    /// runtime uses for ambient scope, drives a real `SELECT` through
1270    /// `execute_query`, then inspects the result cache that the SELECT
1271    /// path populates via `frame.cache_key()`. The key only carries
1272    /// the tenant + identity *because* it was built through the frame —
1273    /// reverting the wiring to inline `current_tenant()` /
1274    /// `current_auth_identity()` reads would still pass this test, but
1275    /// dropping the frame entirely (so the SELECT path stopped touching
1276    /// `cache_key`) would break it.
1277    #[test]
1278    fn select_path_routes_through_frame_cache_key() {
1279        reset_thread_locals();
1280        set_current_tenant("acme".to_string());
1281        set_current_auth_identity("alice".to_string(), Role::Read);
1282
1283        let rt = fresh_runtime();
1284        let result = rt
1285            .execute_query("SELECT 1")
1286            .expect("SELECT 1 executes under tenant=acme/identity=alice");
1287        assert_eq!(result.statement_type, "select");
1288
1289        // The textual SELECT path builds a frame and
1290        // writes its result through `frame.cache_key()`. That key folds
1291        // tenant + identity in via `result_cache_key`, so finding "acme"
1292        // and "alice" inside any cached key proves the frame was the
1293        // seam used.
1294        let cache = rt.inner.result_cache.read();
1295        let any_keyed_with_scope = cache
1296            .0
1297            .keys()
1298            .any(|k| k.contains("acme") && k.contains("alice"));
1299        assert!(
1300            any_keyed_with_scope,
1301            "expected at least one result-cache key carrying tenant+identity, \
1302             got keys: {:?}",
1303            cache.0.keys().collect::<Vec<_>>()
1304        );
1305
1306        reset_thread_locals();
1307    }
1308
1309    /// A SELECT that calls a volatile builtin (here:
1310    /// `pg_advisory_unlock`, the volatile token the runtime currently
1311    /// recognises in `query_has_volatile_builtin`) must NOT populate
1312    /// the result cache. Any caller hitting the cache after this would
1313    /// see a stale answer for an inherently-volatile query, so the
1314    /// SELECT path gates writes through `frame.should_cache_result()`.
1315    ///
1316    /// Deletion test: removing `ReadFrame::should_cache_result`, or
1317    /// reverting the SELECT path to skip its safety gate, would let
1318    /// the result cache silently absorb this statement and break the
1319    /// assertion below.
1320    #[test]
1321    fn volatile_select_does_not_populate_result_cache() {
1322        reset_thread_locals();
1323        let rt = fresh_runtime();
1324
1325        // Frame-level invariant: the volatile-builtin signal collapses
1326        // `should_cache_result` to false even for an autocommit /
1327        // out-of-tx connection.
1328        let frame = StatementExecutionFrame::build(
1329            &rt,
1330            StatementIdentity::Text("SELECT pg_advisory_unlock(1)"),
1331        )
1332        .expect("frame");
1333        let f: &dyn ReadFrame = &frame;
1334        assert!(
1335            !f.should_cache_result(),
1336            "volatile builtin must disable result-cache safety"
1337        );
1338
1339        // End-to-end: drive the volatile SELECT through `execute_query`
1340        // and confirm no entry was stamped under its cache key. Other
1341        // entries from prior tests sharing the binary may exist, so we
1342        // assert specifically on this query's key.
1343        let _ = rt
1344            .execute_query("SELECT pg_advisory_unlock(1)")
1345            .expect("volatile SELECT executes");
1346        let cache = rt.inner.result_cache.read();
1347        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1348        assert!(
1349            !cache.0.contains_key(&key),
1350            "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1351            cache.0.keys().collect::<Vec<_>>()
1352        );
1353
1354        reset_thread_locals();
1355    }
1356
1357    #[test]
1358    fn blob_cache_backend_populates_blob_path_without_legacy_write() {
1359        reset_thread_locals();
1360        let rt = fresh_runtime();
1361        rt.inner
1362            .db
1363            .store()
1364            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1365
1366        let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1367        assert_eq!(result.statement_type, "select");
1368
1369        let key = result_cache_key("SELECT 1");
1370        assert!(
1371            rt.inner
1372                .result_blob_cache
1373                .get("runtime.result_cache", &key)
1374                .is_some(),
1375            "blob backend should stamp the Blob Cache path"
1376        );
1377        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1378        assert!(
1379            !rt.inner.result_cache.read().0.contains_key(&key),
1380            "blob backend should not write the legacy map"
1381        );
1382    }
1383
1384    #[test]
1385    fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1386        reset_thread_locals();
1387        let rt = fresh_runtime();
1388        rt.inner
1389            .db
1390            .store()
1391            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1392
1393        let _ = rt
1394            .execute_query("SELECT pg_advisory_unlock(1)")
1395            .expect("volatile SELECT executes");
1396        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1397        assert!(
1398            rt.inner
1399                .result_blob_cache
1400                .get("runtime.result_cache", &key)
1401                .is_none(),
1402            "volatile SELECT must not populate blob result cache"
1403        );
1404        assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1405    }
1406
1407    #[test]
1408    fn shadow_backend_dual_writes_and_reports_no_divergence_on_equal_results() {
1409        reset_thread_locals();
1410        let rt = fresh_runtime();
1411        rt.inner
1412            .db
1413            .store()
1414            .set_config_tree("runtime.result_cache.backend", &crate::json!("shadow"));
1415
1416        let first = rt.execute_query("SELECT 1").expect("first SELECT");
1417        let second = rt.execute_query("SELECT 1").expect("cached SELECT");
1418        assert_eq!(first.result.len(), second.result.len());
1419
1420        let key = result_cache_key("SELECT 1");
1421        assert!(rt.inner.result_cache.read().0.contains_key(&key));
1422        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1423        assert_eq!(rt.result_cache_shadow_divergences(), 0);
1424        assert_eq!(
1425            crate::runtime::METRIC_CACHE_SHADOW_DIVERGENCE_TOTAL,
1426            "cache_shadow_divergence_total"
1427        );
1428    }
1429
1430    #[test]
1431    fn as_of_on_red_collection_records_floor() {
1432        reset_thread_locals();
1433        let rt = fresh_runtime();
1434
1435        // `red_*` collections always allow AS OF. The frame should
1436        // resolve to a concrete xid and surface it via the Interface.
1437        let frame = StatementExecutionFrame::build(
1438            &rt,
1439            StatementIdentity::Text("SELECT * FROM red_commits AS OF SNAPSHOT 1"),
1440        )
1441        .expect("AS OF SNAPSHOT 1 on red_commits resolves");
1442
1443        let f: &dyn ReadFrame = &frame;
1444        assert_eq!(
1445            f.as_of_floor(),
1446            Some(1),
1447            "AS OF SNAPSHOT 1 records xid=1 as the floor"
1448        );
1449        assert_eq!(f.snapshot().xid, 1);
1450        assert!(
1451            f.snapshot().in_progress.is_empty(),
1452            "historical reads have no in-progress set"
1453        );
1454    }
1455
1456    /// The frame classifies common SQL prefixes into the coarse
1457    /// `Privilege` / `LockIntent` buckets at build time. This test
1458    /// pins the mapping so a regression that silently re-routes
1459    /// (e.g. INSERT classified as Read) surfaces here, not at a
1460    /// downstream privilege gate.
1461    #[test]
1462    fn frame_classifies_privilege_and_lock_intent_from_prefix() {
1463        reset_thread_locals();
1464        let rt = fresh_runtime();
1465
1466        let cases = [
1467            ("SELECT 1", Privilege::Read, LockIntent::Shared),
1468            ("LIST KV settings", Privilege::Read, LockIntent::Shared),
1469            (
1470                "KV GET settings.feature",
1471                Privilege::Read,
1472                LockIntent::Shared,
1473            ),
1474            ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
1475            (
1476                "INSERT INTO t (id) VALUES (1)",
1477                Privilege::Write,
1478                LockIntent::Exclusive,
1479            ),
1480            (
1481                "KV PUT settings.feature = 'on'",
1482                Privilege::Write,
1483                LockIntent::Exclusive,
1484            ),
1485            (
1486                "VAULT PUT secrets.api = 'x'",
1487                Privilege::Write,
1488                LockIntent::Exclusive,
1489            ),
1490            (
1491                "UPDATE t SET x = 1 WHERE id = 1",
1492                Privilege::Write,
1493                LockIntent::Exclusive,
1494            ),
1495            (
1496                "DELETE FROM t WHERE id = 1",
1497                Privilege::Write,
1498                LockIntent::Exclusive,
1499            ),
1500            (
1501                "CREATE TABLE foo (id INT)",
1502                Privilege::Write,
1503                LockIntent::Exclusive,
1504            ),
1505            ("BEGIN", Privilege::None, LockIntent::None),
1506            ("COMMIT", Privilege::None, LockIntent::None),
1507            ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
1508        ];
1509
1510        for (q, want_priv, want_lock) in cases {
1511            let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(q))
1512                .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
1513            let f: &dyn ReadFrame = &frame;
1514            assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
1515            assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
1516        }
1517    }
1518
1519    /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
1520    /// driven through `execute_query` under an identity whose role
1521    /// doesn't satisfy the frame's coarse `Read` privilege gets
1522    /// denied with the frame's signal.
1523    ///
1524    /// We test the gate by classifying an INSERT (which the frame
1525    /// reports as `Privilege::Write`) under `Role::Read` — the only
1526    /// pair the legacy fallback would also reject, but here the
1527    /// rejection comes through `frame.check_query_privilege` BEFORE
1528    /// the parsed-expression walker runs. Removing
1529    /// `required_privilege` (or the `is_satisfied_by` consult inside
1530    /// `check_query_privilege`) would force the deny path back to the
1531    /// inline `RedDBRuntime::check_query_privilege` walker — but the
1532    /// auth_store gate up there is bypassed when no auth_store is
1533    /// wired (embedded test mode), so this test would FLIP from
1534    /// denied to permitted and break the assertion below.
1535    #[test]
1536    fn insert_under_read_role_denied_via_frame_privilege() {
1537        reset_thread_locals();
1538        set_current_auth_identity("alice".to_string(), Role::Read);
1539
1540        let rt = fresh_runtime();
1541        // Bypass parser by reaching into the frame directly: the
1542        // frame derives privilege from the SQL prefix without
1543        // needing an auth_store wired up. Driving end-to-end via
1544        // `execute_query` would also reject (no table `t`), but for
1545        // a different reason — we want to pin the privilege seam.
1546        let frame = StatementExecutionFrame::build(
1547            &rt,
1548            StatementIdentity::Text("INSERT INTO t (id) VALUES (1)"),
1549        )
1550        .expect("frame builds for INSERT");
1551        let f: &dyn ReadFrame = &frame;
1552        assert_eq!(
1553            f.required_privilege(),
1554            Privilege::Write,
1555            "INSERT classified as Write"
1556        );
1557        let id = f.identity().expect("identity captured");
1558        assert!(
1559            !f.required_privilege().is_satisfied_by(id.1),
1560            "Role::Read does not satisfy Privilege::Write — frame must deny"
1561        );
1562
1563        // End-to-end: the frame's `check_query_privilege` sees the
1564        // (Read role, Write privilege) mismatch and denies before
1565        // dispatch. We drive a synthetic `QueryExpr::Table` because
1566        // the SELECT/INSERT parser would happen to also fail, and we
1567        // want the failure to come from the privilege seam.
1568        use reddb_rql::ast::{QueryExpr, TableQuery};
1569        let expr = QueryExpr::Table(TableQuery::new("t"));
1570        let err = frame
1571            .check_query_privilege(&rt, &expr)
1572            .expect_err("denied via frame's coarse privilege gate");
1573        let msg = format!("{err}");
1574        assert!(
1575            msg.contains("permission denied") && msg.contains("Write"),
1576            "expected frame-level Write deny, got: {msg}"
1577        );
1578
1579        reset_thread_locals();
1580    }
1581
1582    /// End-to-end proof that the frame-owned row-buffer arena (#885) is
1583    /// wired into the SELECT path and produces observable results
1584    /// byte-identical to the per-request-allocation baseline.
1585    ///
1586    /// A table with more rows than the streaming high-water mark
1587    /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
1588    /// path to assemble many chunks, each leasing/recycling the frame
1589    /// arena's single chunk buffer. Driving it through `execute_query`
1590    /// (which builds a `StatementExecutionFrame` and lends its arena)
1591    /// must return every inserted row, in order — exactly what the
1592    /// allocate-per-chunk path returned. A bug in the arena wiring
1593    /// (dropped rows, bled rows, mis-ordering) would surface here.
1594    #[test]
1595    fn large_select_through_frame_arena_returns_all_rows_in_order() {
1596        reset_thread_locals();
1597        let rt = fresh_runtime();
1598        rt.execute_query("CREATE TABLE big (id INT)")
1599            .expect("create table");
1600
1601        // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
1602        // spans multiple chunks and the arena buffer is reused.
1603        const N: usize = 2_500;
1604        for start in (0..N).step_by(250) {
1605            let end = (start + 250).min(N);
1606            let values = (start..end)
1607                .map(|i| format!("({i})"))
1608                .collect::<Vec<_>>()
1609                .join(", ");
1610            rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
1611                .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
1612        }
1613
1614        let result = rt
1615            .execute_query("SELECT id FROM big ORDER BY id")
1616            .expect("large SELECT executes through the frame arena path");
1617        assert_eq!(result.statement_type, "select");
1618        assert_eq!(
1619            result.result.records.len(),
1620            N,
1621            "every inserted row streams back through the arena-backed channel"
1622        );
1623        for (i, record) in result.result.records.iter().enumerate() {
1624            assert_eq!(
1625                record.get("id"),
1626                Some(&reddb_types::Value::Integer(i as i64)),
1627                "row {i} is byte-identical to the per-request-allocation baseline"
1628            );
1629        }
1630
1631        reset_thread_locals();
1632    }
1633
1634    /// Transport adapters may decode their wire-specific parameter value
1635    /// shapes, but SQL parsing/binding must stay behind the runtime's
1636    /// statement entrypoint. This pins the deeper seam introduced for
1637    /// parameterized query execution. HTTP, JSON-RPC, RedWire, and gRPC hand
1638    /// a `QueryRequest` to the Request module, as does PG wire through the
1639    /// shared prepared registry. All paths install a real
1640    /// `StatementExecutionFrame` before dispatch.
1641    ///
1642    /// The Request transports reach the runtime entrypoint one layer deeper:
1643    /// `QueryRequestExecutor` calls `execute_query_with_params` on their
1644    /// behalf. Their delegation target is the Request seam, so that is what
1645    /// is pinned here — the "no binding in the adapter" half is unchanged
1646    /// for all.
1647    #[test]
1648    fn parameterized_transport_adapters_delegate_binding_to_runtime() {
1649        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1650        let adapters = [
1651            ("src/server/handlers_query.rs", "QueryRequestExecutor"),
1652            ("src/rpc_stdio.rs", "QueryRequestExecutor"),
1653            ("src/wire/redwire/session.rs", "QueryRequestExecutor"),
1654            ("src/wire/postgres/server.rs", "QueryRequestExecutor"),
1655            ("src/grpc.rs", "QueryRequestExecutor"),
1656        ];
1657
1658        for (relative, delegation) in adapters {
1659            let path = manifest_dir.join(relative);
1660            let text = std::fs::read_to_string(&path)
1661                .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
1662            assert!(
1663                text.contains(delegation),
1664                "{relative} should delegate parameterized query execution through {delegation}"
1665            );
1666            assert!(
1667                !text.contains("user_params::bind"),
1668                "{relative} must not bind SQL params in the transport adapter"
1669            );
1670        }
1671
1672        // The Request module is the only place RedWire's delegation may
1673        // bottom out: it must still reach the runtime entrypoint itself.
1674        let seam = std::fs::read_to_string(manifest_dir.join("src/runtime/query_request.rs"))
1675            .expect("read src/runtime/query_request.rs");
1676        assert!(
1677            seam.contains("execute_query_with_params"),
1678            "the Request module must execute text SQL through the runtime entrypoint"
1679        );
1680    }
1681
1682    /// Deletion-test for `ReadFrame::lock_intent`: a transaction
1683    /// control statement carries `LockIntent::None` and the
1684    /// `acquire_intent_locks` path returns `None` without consulting
1685    /// `intent_lock_modes_for`. Removing the method (or its consult
1686    /// site in `acquire_intent_locks`) would force the lock-mode
1687    /// helper to walk a fabricated parsed expression to reach the
1688    /// same conclusion — but the assertion that no guard is allocated
1689    /// for a `BEGIN` frame would still hold, so we additionally pin
1690    /// the classifier mapping above to make the deletion observable.
1691    #[test]
1692    fn control_statement_skips_intent_locks_via_frame() {
1693        reset_thread_locals();
1694        let rt = fresh_runtime();
1695
1696        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("BEGIN"))
1697            .expect("frame builds for BEGIN");
1698        let f: &dyn ReadFrame = &frame;
1699        assert_eq!(f.lock_intent(), LockIntent::None);
1700
1701        // Drive `acquire_intent_locks` against a fabricated SELECT
1702        // expression that WOULD normally yield `(IS, IS)`; the frame's
1703        // `lock_intent() == None` short-circuit must still suppress
1704        // the guard.
1705        use reddb_rql::ast::{QueryExpr, TableQuery};
1706        let expr = QueryExpr::Table(TableQuery::new("t"));
1707        let guard = frame.acquire_intent_locks(&rt, &expr);
1708        assert!(
1709            guard.is_none(),
1710            "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
1711        );
1712    }
1713
1714    /// Nested sub-execution inside one statement (CTE prelude,
1715    /// materialized-view refresh, inline graph TVF) must read the
1716    /// enclosing statement's snapshot. Autocommit is the case that used
1717    /// to break: `current_snapshot()` mints a fresh high-water mark per
1718    /// call, so an inline graph TVF could materialize its nodes and its
1719    /// edges under two different snapshots within one statement.
1720    #[test]
1721    fn nested_frame_inherits_outer_statement_snapshot() {
1722        reset_thread_locals();
1723        let rt = fresh_runtime();
1724        rt.execute_query("CREATE TABLE nested_snap (id INT)")
1725            .expect("create table");
1726
1727        let outer = StatementExecutionFrame::build(
1728            &rt,
1729            StatementIdentity::Text("SELECT * FROM nested_snap"),
1730        )
1731        .expect("outer frame builds");
1732        let _outer_guards = outer.install(&rt);
1733
1734        // A write between the two builds moves the high-water mark, so a
1735        // re-minting nested frame would land on a different xid.
1736        rt.execute_query("INSERT INTO nested_snap (id) VALUES (1)")
1737            .expect("concurrent insert");
1738
1739        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("nested_snap"));
1740        let nested = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1741            .expect("nested frame builds");
1742
1743        assert_eq!(
1744            ReadFrame::snapshot(&nested).xid,
1745            ReadFrame::snapshot(&outer).xid,
1746            "nested execution must inherit the outer statement's snapshot"
1747        );
1748    }
1749
1750    /// The other side of the same rule, and the one that matters more:
1751    /// a nested statement built from *text* is a statement in its own
1752    /// right, not part of the enclosing one, so it must mint a fresh
1753    /// snapshot and observe everything committed before it.
1754    ///
1755    /// `apply_batched` (`impl_migrations.rs`) is the load-bearing caller:
1756    /// it drives `APPLY MIGRATION … BATCH N ROWS` by re-running the body
1757    /// with `LIMIT N` through `execute_query` until fewer than `N` rows
1758    /// are affected. Inheriting the outer `APPLY MIGRATION` statement's
1759    /// snapshot froze that loop's view, so every iteration re-selected
1760    /// the same rows and the migration never converged — 25 rows at
1761    /// `BATCH 7` hung instead of finishing in four iterations.
1762    #[test]
1763    fn nested_text_statement_sees_commits_made_since_the_outer_frame() {
1764        reset_thread_locals();
1765        let rt = fresh_runtime();
1766        rt.execute_query("CREATE TABLE nested_text_snap (id INT)")
1767            .expect("create table");
1768
1769        let outer = StatementExecutionFrame::build(
1770            &rt,
1771            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1772        )
1773        .expect("outer frame builds");
1774        let _outer_guards = outer.install(&rt);
1775
1776        rt.execute_query("INSERT INTO nested_text_snap (id) VALUES (1)")
1777            .expect("concurrent insert");
1778
1779        let nested = StatementExecutionFrame::build(
1780            &rt,
1781            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1782        )
1783        .expect("nested frame builds");
1784
1785        assert_ne!(
1786            ReadFrame::snapshot(&nested).xid,
1787            ReadFrame::snapshot(&outer).xid,
1788            "a nested text statement must mint a fresh snapshot, not inherit \
1789             the enclosing statement's frozen view"
1790        );
1791    }
1792
1793    /// A frame with no statement text must not silently answer an
1794    /// `AS OF` read with live data. `TableQuery::as_of` is consumed
1795    /// nowhere in the runtime or scan path, so the only correct
1796    /// behaviours are "resolve it" (text path) or "refuse".
1797    #[test]
1798    fn expr_frame_rejects_as_of_instead_of_reading_live() {
1799        reset_thread_locals();
1800        let rt = fresh_runtime();
1801
1802        let mut table = reddb_rql::ast::TableQuery::new("red_commits");
1803        table.as_of = Some(reddb_rql::ast::AsOfClause::Snapshot(1));
1804        let expr = QueryExpr::Table(table);
1805
1806        let err = match StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr)) {
1807            Err(err) => err,
1808            Ok(_) => panic!("AS OF without statement text must be refused, not read live"),
1809        };
1810        let msg = format!("{err}");
1811        assert!(
1812            msg.contains("AS OF cannot be resolved from a pre-parsed expression"),
1813            "expected a loud AS OF refusal, got: {msg}"
1814        );
1815    }
1816
1817    /// The expression path has no stable text key, so it must opt out of
1818    /// the result cache entirely. A kind label (`"table"`, `"insert"`)
1819    /// would key every statement of the same kind to one cache slot.
1820    #[test]
1821    fn expr_frame_opts_out_of_the_result_cache() {
1822        reset_thread_locals();
1823        let rt = fresh_runtime();
1824
1825        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("t"));
1826        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1827            .expect("expr frame builds");
1828
1829        assert!(frame.cache_key().is_empty(), "expr frame has no cache key");
1830        assert!(
1831            !frame.can_read_result_cache(),
1832            "expr frame must never serve from the result cache"
1833        );
1834    }
1835}