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