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, SnapshotRetentionGuard, 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        b"SCRUB" => "uncached",
247        _ => "unknown",
248    }
249}
250
251fn statement_uses_result_cache(query: &str) -> bool {
252    !matches!(
253        statement_kind(query),
254        "write" | "ddl" | "admin" | "control" | "uncached"
255    ) && !super::red_schema::is_computed_stats_query(query)
256}
257
258/// Refuse a pre-parsed expression that carries a top-level `AS OF`.
259///
260/// `AS OF` is resolved from the statement text (`AsOfSpec` -> commit /
261/// branch / tag / timestamp -> xid) and `TableQuery::as_of` is consumed
262/// nowhere in the runtime or scan path. An expression-only frame
263/// therefore cannot honour it, and installing a live snapshot for a
264/// historical read would answer with current rows presented as history.
265/// Fail loudly instead; callers that have the text (`execute_query`,
266/// `execute_prepared_query`, `execute_query_with_params`) resolve it
267/// correctly. Mirrors `peek_top_level_as_of_with_table`, which likewise
268/// only recognises a top-level `AS OF`.
269fn reject_unresolvable_as_of(expr: &QueryExpr) -> RedDBResult<()> {
270    let QueryExpr::Table(table) = expr else {
271        return Ok(());
272    };
273    if table.as_of.is_none() {
274        return Ok(());
275    }
276    Err(RedDBError::InvalidOperation(
277        "AS OF cannot be resolved from a pre-parsed expression — \
278         execute the statement text (execute_query / execute_prepared_query / \
279         execute_query_with_params) so the historical snapshot is resolved"
280            .to_string(),
281    ))
282}
283
284fn classify_privilege(query: &str) -> Privilege {
285    match statement_kind(query) {
286        "read" => Privilege::Read,
287        "write" => Privilege::Write,
288        // DDL is gated at `Role::Write` in the legacy fallback (see
289        // `RedDBRuntime::check_query_privilege` for CreateTable et al.),
290        // so it classifies as Write here. APPLY / ROLLBACK MIGRATION and
291        // GRANT / REVOKE upgrade to Admin via finer checks at the call
292        // site — the frame surfaces only the coarse class.
293        "ddl" => Privilege::Write,
294        "admin" => Privilege::Admin,
295        _ => Privilege::None,
296    }
297}
298
299fn classify_lock_intent(query: &str) -> LockIntent {
300    match statement_kind(query) {
301        "read" => LockIntent::Shared,
302        "write" | "ddl" => LockIntent::Exclusive,
303        _ => LockIntent::None,
304    }
305}
306
307pub(super) struct StatementExecutionFrame {
308    tx_local_tenant: Option<Option<String>>,
309    snapshot: Snapshot,
310    snapshot_retention: RefCell<Option<SnapshotRetentionGuard>>,
311    own_xids: HashSet<Xid>,
312    serializable_reader: Option<Xid>,
313    cache_key: String,
314    cache_data_epoch: Option<u64>,
315    cache_revision: u64,
316    is_volatile_query: bool,
317    cache_safe: bool,
318    /// Effective tenant captured at frame-build time after WITHIN /
319    /// SET LOCAL TENANT / SET TENANT resolution. Stored on the frame
320    /// so the `ReadFrame` Interface can return a borrow without
321    /// re-touching the thread-local stack.
322    effective_scope: Option<String>,
323    /// Auth identity captured at frame-build time. `None` for
324    /// embedded / anonymous callers.
325    identity: Option<(String, Role)>,
326    /// `Some(xid)` when AS OF resolved to a historical xid; `None`
327    /// for live reads.
328    as_of_floor: Option<Xid>,
329    /// True when the statement snapshot can require tuple versions that
330    /// current secondary indexes no longer contain.
331    requires_index_fallback: bool,
332    /// Privilege class required by the statement, derived from the
333    /// SQL text at frame-build time. Read/write dispatch sites
334    /// consult this instead of re-classifying the parsed expression.
335    required_privilege: Privilege,
336    /// Collection-level lock intent the statement implies. The
337    /// lock-acquisition path short-circuits when this is `None`.
338    lock_intent: LockIntent,
339    /// Set of collection ids the active `(tenant, role)` scope is
340    /// allowed to observe. Computed at frame-build time via the
341    /// `AuthStore` visibility cache and consumed by `AuthorizedSearch`
342    /// to gate SEARCH SIMILAR / SEARCH CONTEXT candidate sets before
343    /// scoring (issue #119). `None` when no auth store is wired
344    /// (embedded test mode) — AI search refuses on `None`.
345    visible_collections: Option<HashSet<String>>,
346    /// Per-owner buffer arena for query-result row chunks (#885). Owned
347    /// by the frame because the frame already owns the query lifecycle;
348    /// lent to the row-streaming path (`execute_runtime_table_query_in`)
349    /// so chunk buffers are reused across the statement's chunk-fetches
350    /// instead of allocated fresh per chunk. Reclaimed when the frame
351    /// drops at statement end — no `thread_local!` scratch, which would
352    /// be unsound under tokio's work-stealing runtime.
353    row_arena: Rc<RefCell<super::query_exec::RowBufferArena>>,
354}
355
356pub(super) struct StatementFrameGuards {
357    _tx_local_guard: TxLocalTenantGuard,
358    _config_snapshot_guard: ConfigSnapshotGuard,
359    _secret_store_guard: SecretStoreGuard,
360    _kv_store_guard: KvStoreGuard,
361    _snapshot_guard: CurrentSnapshotGuard,
362}
363
364pub(super) struct PreparedStatement {
365    pub(super) expr: QueryExpr,
366    pub(super) mode: QueryMode,
367}
368
369/// What a `StatementExecutionFrame` is built from.
370///
371/// The frame derives the statement snapshot (including any `AS OF`
372/// floor), the coarse privilege class, the lock intent, the volatility
373/// flag, and the result-cache key from its input. Feeding it anything
374/// other than the statement itself makes every one of those describe a
375/// different statement than the one that runs, so the input is named
376/// explicitly here rather than being "some string".
377#[derive(Clone, Copy)]
378pub(super) enum StatementIdentity<'a> {
379    /// The statement's SQL text. All five derivations are exact.
380    Text(&'a str),
381    /// A direct runtime read that needs the text-derived snapshot, scope and
382    /// privilege context but does not traverse result-cache read/write paths.
383    UncachedText(&'a str),
384    /// A pre-parsed expression with no accompanying text — the nested
385    /// in-statement callers (CTE prelude, materialized-view refresh,
386    /// inline graph TVF) hold an expression the outer statement already
387    /// parsed.
388    ///
389    /// Text-derived facts are not guessed from a kind label: there is no
390    /// stable cache key (a label would collide across every statement of
391    /// the same kind), and the coarse privilege / lock short-circuits are
392    /// left at `None` so the deep grant engine stays the gate. `AS OF` is
393    /// rejected up front by `build` because it cannot be resolved without
394    /// the text.
395    Expr(&'a QueryExpr),
396}
397
398impl<'a> StatementIdentity<'a> {
399    /// SQL text when the frame was built from text, `""` otherwise. Used
400    /// for the text-derived classifications, all of which degrade to the
401    /// deliberately inert `unknown` / `None` classes on the expr path.
402    fn text(&self) -> &'a str {
403        match self {
404            Self::Text(query) | Self::UncachedText(query) => query,
405            Self::Expr(_) => "",
406        }
407    }
408}
409
410impl StatementExecutionFrame {
411    pub(super) fn build(
412        runtime: &RedDBRuntime,
413        identity_source: StatementIdentity<'_>,
414    ) -> RedDBResult<Self> {
415        let query = identity_source.text();
416        let uses_result_cache = matches!(identity_source, StatementIdentity::Text(query) if statement_uses_result_cache(query));
417        let (cache_data_epoch, cache_revision) = match identity_source {
418            StatementIdentity::Text(_) if uses_result_cache => runtime.result_cache_snapshot(),
419            StatementIdentity::Text(_)
420            | StatementIdentity::UncachedText(_)
421            | StatementIdentity::Expr(_) => (None, 0),
422        };
423        if let StatementIdentity::Expr(expr) = identity_source {
424            reject_unresolvable_as_of(expr)?;
425        }
426        let conn_id = current_connection_id();
427        let tx_local_tenant = runtime.inner.transaction_state.local_tenant(conn_id);
428        let tx_context = runtime.inner.transaction_state.context(conn_id);
429        let own_xids = own_transaction_xids(tx_context.as_ref());
430        let serializable_reader = tx_context
431            .as_ref()
432            .filter(|ctx| {
433                ctx.isolation == crate::storage::transaction::IsolationLevel::Serializable
434            })
435            .map(|ctx| ctx.xid);
436        let (snapshot, as_of_floor, snapshot_retention) =
437            runtime.statement_snapshot(query, identity_source)?;
438        let requires_index_fallback =
439            as_of_floor.is_some() || tx_context.is_some() || !snapshot.in_progress.is_empty();
440        let (cache_key, is_volatile_query) = match identity_source {
441            StatementIdentity::Text(_) if uses_result_cache => (
442                result_cache_key(query),
443                query_has_volatile_builtin(query) || query_is_ask_statement(query),
444            ),
445            // No text means no stable key, so the frame opts out of the
446            // result cache on both sides: an empty key is never read from
447            // or written to, and `is_volatile_query` is set so
448            // `should_cache_result` refuses regardless of the key.
449            StatementIdentity::Text(_)
450            | StatementIdentity::UncachedText(_)
451            | StatementIdentity::Expr(_) => (String::new(), true),
452        };
453        let cache_safe = runtime.result_cache_safe(conn_id);
454        // Capture identity + effective scope under the same
455        // thread-local view that the cache key was built from, so
456        // the Interface and the cache key agree on what "this
457        // statement" means.
458        let effective_scope = current_tenant();
459        let identity = current_auth_identity();
460
461        // Coarse classification of the statement, computed once from
462        // the SQL prefix so downstream callers don't re-derive it
463        // from the parsed `QueryExpr` at every privilege / lock site.
464        // On the expr path `query` is empty, so both classify as the
465        // inert `None` class: the coarse gate can only deny, never
466        // allow (see `check_query_privilege`), so declining to
467        // short-circuit leaves the deep grant engine as the gate.
468        let required_privilege = classify_privilege(query);
469        let lock_intent = classify_lock_intent(query);
470
471        // Issue #119: resolve the visible-collections set for the
472        // active (tenant, role) scope. Only meaningful when an auth
473        // store is wired *and* an identity was captured — embedded
474        // anonymous callers fall back to `None`, and AI search call
475        // sites refuse on `None`.
476        let visible_collections = match (runtime.inner.auth_store.read().clone(), identity.as_ref())
477        {
478            (Some(store), Some((principal, role))) => {
479                let collections = runtime.inner.db.store().list_collections();
480                Some(store.visible_collections_for_scope(
481                    effective_scope.as_deref(),
482                    *role,
483                    principal,
484                    &collections,
485                ))
486            }
487            _ => None,
488        };
489
490        Ok(Self {
491            tx_local_tenant,
492            snapshot,
493            snapshot_retention: RefCell::new(Some(snapshot_retention)),
494            own_xids,
495            serializable_reader,
496            cache_key,
497            cache_data_epoch,
498            cache_revision,
499            is_volatile_query,
500            cache_safe,
501            effective_scope,
502            identity,
503            as_of_floor,
504            requires_index_fallback,
505            required_privilege,
506            lock_intent,
507            visible_collections,
508            row_arena: Rc::new(RefCell::new(super::query_exec::RowBufferArena::new())),
509        })
510    }
511
512    /// Lend the frame's per-owner row-buffer arena (#885) to the
513    /// row-streaming path. Returns a cloned `Rc` handle; the frame remains
514    /// the owner and the arena is reclaimed when the frame drops at
515    /// statement end.
516    pub(super) fn row_arena(&self) -> Rc<RefCell<super::query_exec::RowBufferArena>> {
517        Rc::clone(&self.row_arena)
518    }
519
520    pub(super) fn install(&self, runtime: &RedDBRuntime) -> StatementFrameGuards {
521        StatementFrameGuards {
522            _tx_local_guard: TxLocalTenantGuard::install(self.tx_local_tenant.clone()),
523            _config_snapshot_guard: ConfigSnapshotGuard::install(
524                Arc::clone(&runtime.inner.db),
525                runtime.inner.auth_store.read().clone(),
526            ),
527            _secret_store_guard: SecretStoreGuard::install(runtime),
528            _kv_store_guard: KvStoreGuard::install(runtime.inner.auth_store.read().clone()),
529            _snapshot_guard: CurrentSnapshotGuard::install(SnapshotContext {
530                snapshot: self.snapshot.clone(),
531                manager: runtime.inner.transaction_state.snapshot_manager(),
532                own_xids: self.own_xids.clone(),
533                requires_index_fallback: self.requires_index_fallback,
534                serializable_reader: self.serializable_reader,
535            }),
536        }
537    }
538
539    pub(super) fn cache_key(&self) -> &str {
540        &self.cache_key
541    }
542
543    pub(super) fn can_read_result_cache(&self) -> bool {
544        // Delegates to the `ReadFrame` Interface so the volatile +
545        // active-tx safety decision lives in exactly one place.
546        <Self as ReadFrame>::should_cache_result(self)
547    }
548
549    pub(super) fn should_write_result_cache(&self, result: &RuntimeQueryResult) -> bool {
550        // Cache-safety (volatile builtin, active-tx writes) comes from
551        // the Interface; the rest are write-side payload heuristics
552        // (statement shape, result size) that aren't part of the
553        // safety contract.
554        <Self as ReadFrame>::should_cache_result(self)
555            && result.statement_type == "select"
556            && result.engine != "vault"
557            && result.engine != "runtime-rank"
558            // `QUEUE READ` is a stateful read: a delayed message
559            // (issue #722) becomes deliverable over time without a
560            // producer push to invalidate the cache, so a cached empty
561            // result would hide it. Skip caching entirely.
562            && result.statement != "queue_group_read"
563            // SCRUB is a stateful read too: the background form advances
564            // a pacing cursor per tick (a cached tick would replay the
565            // summary and never advance), and even a full pass must
566            // re-read the file to see new corruption. Never cache.
567            && result.engine != "runtime-scrub"
568            && result.result.pre_serialized_json.is_none()
569            // Graph-analytics TVF output (issue #802) is deterministic and
570            // expensive to recompute, so it is cached at any row count. The
571            // ≤5-row heuristic only bounds payload size for ordinary SELECTs.
572            && (is_graph_tvf_engine(result.engine) || result.result.records.len() <= 5)
573    }
574
575    pub(super) fn read_result_cache(&self, runtime: &RedDBRuntime) -> Option<RuntimeQueryResult> {
576        if runtime.resource_limits().read_execution_limits_enabled() {
577            return None;
578        }
579        if self.can_read_result_cache() {
580            runtime
581                .get_result_cache_entry(self.cache_key(), self.cache_data_epoch)
582                .map(|mut result| {
583                    if let Some(vector) = result.result.stats.vector.as_mut() {
584                        vector.cache_hit = true;
585                    }
586                    result
587                })
588        } else {
589            None
590        }
591    }
592
593    pub(super) fn write_result_cache(
594        &self,
595        runtime: &RedDBRuntime,
596        result: &RuntimeQueryResult,
597        scopes: HashSet<String>,
598    ) {
599        if self.should_write_result_cache(result) {
600            runtime.put_result_cache_entry(
601                self.cache_key(),
602                RuntimeResultCacheEntry {
603                    result: result.clone(),
604                    cached_at: std::time::Instant::now(),
605                    scopes,
606                    data_epoch: self.cache_data_epoch.unwrap_or(0),
607                },
608                self.cache_data_epoch,
609                self.cache_revision,
610            );
611        }
612    }
613
614    pub(super) fn prepare_cte(&self, query: &str) -> RedDBResult<Option<QueryExpr>> {
615        // Detected via cheap prefix check so non-CTE queries skip the
616        // full parse here. CTE-bearing queries bypass the plan cache
617        // and result cache (rare workload — perf optimization is a
618        // follow-up). Inlining substitutes every CTE reference with
619        // its body as a subquery in FROM, after which the existing
620        // subquery-in-FROM machinery handles execution. Recursive
621        // CTEs are rejected explicitly until fixpoint execution wires
622        // through the runtime.
623        if !has_with_prefix(query) {
624            return Ok(None);
625        }
626        let parsed =
627            reddb_rql::parser::parse(query).map_err(|err| RedDBError::Query(err.to_string()))?;
628        if parsed.with_clause.is_some() {
629            let rewritten = crate::storage::query::executors::inline_ctes(parsed)
630                .map_err(|err| RedDBError::Query(err.to_string()))?;
631            return Ok(Some(rewritten));
632        }
633        // No WITH after parse (the prefix matched something else like
634        // `WITHIN` that already routed elsewhere) — fall through to
635        // the normal path with the original query.
636        Ok(None)
637    }
638
639    pub(super) fn prepare_statement(
640        &self,
641        runtime: &RedDBRuntime,
642        query: &str,
643    ) -> RedDBResult<PreparedStatement> {
644        let mode = detect_mode(query);
645        if matches!(mode, QueryMode::Unknown) {
646            return Err(RedDBError::Query("unable to detect query mode".to_string()));
647        }
648
649        // ── Plan cache: reuse only exact-query ASTs ──
650        //
651        // DML statements (INSERT/UPDATE/DELETE) almost always have unique literal
652        // values, so caching them burns CPU on eviction bookkeeping (Vec::remove(0)
653        // shifts the entire LRU list) with zero hit rate. Skip the cache entirely
654        // Plan cache applies to statements whose shape can be
655        // normalised + rebound (`UPDATE t SET x=? WHERE _entity_id=?`
656        // reuses the same plan across thousands of varying literals).
657        // INSERT is still bypassed — its shape changes per column set
658        // and bulk paths don't go through here anyway.
659        let first_word = query
660            .trim()
661            .split_ascii_whitespace()
662            .next()
663            .unwrap_or("")
664            .to_ascii_uppercase();
665        let is_insert = first_word == "INSERT";
666        // #1370 — volatile queries ($config / $secret resolve mutable runtime
667        // state at execution time) must bypass the plan cache too. A cached
668        // optimized plan drops the live `$config` resolution, so a later
669        // `SET CONFIG` would be ignored and the query would serve a stale value.
670        // Re-parse fresh every time so the resolver runs against current state.
671        let bypass_plan_cache = is_insert || self.is_volatile_query;
672
673        // Fused normalize+extract: one byte-scan produces both the
674        // cache_key AND the literal bindings. Saves a second Lexer
675        // pass over the query text on every cache hit — dominant
676        // cost on tight UPDATE loops that hit the same shape
677        // thousands of times with varying literals.
678        let (cache_key, prescan_binds) = if bypass_plan_cache {
679            (String::new(), Vec::new())
680        } else {
681            crate::storage::query::planner::cache_key::normalize_and_extract(query)
682        };
683
684        let expr = if bypass_plan_cache {
685            // Bypass plan cache for INSERT — shape varies per query.
686            parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
687        } else {
688            // ── Hot path: read lock only (no writer serialization on cache hits) ──
689            //
690            // peek() is a non-mutating probe: no LRU promotion, no touch().
691            // This lets concurrent readers proceed without blocking each other.
692            // On hit we bind literals if needed and return immediately.
693            // Only on miss do we drop to a write lock to parse + insert.
694            let hit = {
695                let plan_cache = runtime.inner.query_cache.read();
696                plan_cache.peek(&cache_key).map(|cached| {
697                    let parameter_count = cached.parameter_count;
698                    let optimized = cached.plan.optimized.clone();
699                    let exact_query = cached.exact_query.clone();
700                    (parameter_count, optimized, exact_query)
701                })
702            };
703
704            if let Some((parameter_count, optimized, exact_query)) = hit {
705                if parameter_count > 0 {
706                    // Shape hit: use the binds extracted during normalise.
707                    let shape_binds = prescan_binds.clone();
708                    if let Some(bound) =
709                        crate::storage::query::planner::shape::bind_parameterized_query(
710                            &optimized,
711                            &shape_binds,
712                            parameter_count,
713                        )
714                    {
715                        bound
716                    } else {
717                        // A failed bind leaves placeholders in the cached AST,
718                        // even for the same exact SQL. Never execute it unbound.
719                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
720                    }
721                } else {
722                    // No parameters means either there truly are no literals,
723                    // or this statement type does not participate in shape
724                    // parameterization (for example graph/queue commands).
725                    // Reusing a normalized-cache hit across a different exact
726                    // query can therefore leak stale literals into execution.
727                    if exact_query.as_deref() == Some(query) {
728                        optimized
729                    } else {
730                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
731                    }
732                }
733            } else {
734                // Cache miss — parse, parameterize, store.
735                let parsed =
736                    parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
737                let (cached_expr, parameter_count) = if let Some(prepared) =
738                    crate::storage::query::planner::shape::parameterize_query_expr(&parsed)
739                {
740                    (prepared.shape, prepared.parameter_count)
741                } else {
742                    (parsed.clone(), 0)
743                };
744                {
745                    let mut pc = runtime.inner.query_cache.write();
746                    let plan = crate::storage::query::planner::QueryPlan::new(
747                        parsed.clone(),
748                        cached_expr,
749                        Default::default(),
750                    );
751                    pc.insert(
752                        cache_key.clone(),
753                        crate::storage::query::planner::CachedPlan::new(plan)
754                            .with_shape_key(cache_key.clone())
755                            .with_exact_query(query.to_string())
756                            .with_parameter_count(parameter_count),
757                    );
758                }
759                parsed
760            }
761        };
762
763        // Phase 5 PG parity: substitute any registered view name that
764        // appears in the expression with its stored body. Runs after
765        // parse and before dispatch so the SQL entrypoint gets the
766        // same view resolution `execute_query_expr` already does.
767        let expr = runtime.rewrite_view_refs(expr);
768
769        Ok(PreparedStatement { expr, mode })
770    }
771
772    pub(super) fn check_query_privilege(
773        &self,
774        runtime: &RedDBRuntime,
775        expr: &QueryExpr,
776    ) -> RedDBResult<()> {
777        // Frame-level coarse gate. We consult `required_privilege()`
778        // (computed once at frame-build) against the captured identity
779        // before the deep grant engine walks the parsed expression.
780        // The coarse gate cannot ALLOW anything the grant engine would
781        // deny — it only short-circuits the obvious "Role::Read tries
782        // INSERT" case so a downstream caller never has to redo this
783        // check inline. `Privilege::None` (transaction control / SET /
784        // SHOW) flows through unchanged; the grant engine treats those
785        // as bypass too.
786        if let Some((username, role)) = <Self as ReadFrame>::identity(self) {
787            let needed = <Self as ReadFrame>::required_privilege(self);
788            if !needed.is_satisfied_by(role) {
789                // Issue #205 — when the deep grant engine *also*
790                // denies, we treat this as an ordinary permission
791                // failure. But when an Admin-only statement reaches
792                // this gate without an auth_store wired (so the deep
793                // engine can't double-check), the coarse rejection is
794                // the only line of defence — emit an OperatorEvent so
795                // the operator notices an Admin-class statement was
796                // attempted with insufficient role.
797                if matches!(needed, Privilege::Admin) && runtime.inner.auth_store.read().is_none() {
798                    crate::telemetry::operator_event::OperatorEvent::AuthBypass {
799                        principal: username.to_string(),
800                        resource: format!("statement requiring {needed:?}"),
801                        detail: format!(
802                            "auth_store not wired; coarse gate is sole defence (role={role:?})"
803                        ),
804                    }
805                    .emit_global();
806                }
807                return Err(RedDBError::Query(format!(
808                    "permission denied: principal=`{username}` role=`{role:?}` lacks {needed:?} privilege"
809                )));
810            }
811        }
812        runtime
813            .check_query_privilege(expr)
814            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
815    }
816
817    pub(super) fn prepare_dispatch(
818        &self,
819        runtime: &RedDBRuntime,
820        expr: &QueryExpr,
821    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
822        runtime.validate_model_operations_before_auth(expr)?;
823        self.check_query_privilege(runtime, expr)?;
824        self.release_snapshot_for_maintenance(expr);
825        self.acquire_intent_locks(runtime, expr)
826    }
827
828    pub(super) fn release_snapshot_for_maintenance(&self, expr: &QueryExpr) {
829        if matches!(
830            expr,
831            QueryExpr::MaintenanceCommand(reddb_rql::ast::MaintenanceCommand::Vacuum { .. })
832        ) {
833            // VACUUM inspects physical versions rather than reading its own
834            // statement snapshot. Its temporary pin would otherwise inhibit
835            // reclamation, especially with unused reserved autocommit xids.
836            // Outer/nested reader frames keep their independent pins.
837            self.snapshot_retention.borrow_mut().take();
838        }
839    }
840
841    pub(super) fn acquire_intent_locks(
842        &self,
843        runtime: &RedDBRuntime,
844        expr: &QueryExpr,
845    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
846        use crate::runtime::lock_manager::LockMode;
847        use crate::runtime::locking::{LockerGuard, Resource};
848
849        if !runtime.config_bool("concurrency.locking.enabled", true) {
850            return Ok(None);
851        }
852        // Frame-level short-circuit: if the statement carries no lock
853        // intent (transaction control, SET, SHOW), skip the lock
854        // manager entirely instead of letting `intent_lock_modes_for`
855        // walk the parsed expression to reach the same conclusion.
856        if matches!(<Self as ReadFrame>::lock_intent(self), LockIntent::None) {
857            return Ok(None);
858        }
859        let Some((global_mode, coll_mode)) = intent_lock_modes_for(expr) else {
860            return Ok(None);
861        };
862        // A failed acquire (timeout, deadlock) used to be discarded and the
863        // statement ran unlocked (#2376). Fail it instead; the client retries.
864        let acquire = |guard: &mut LockerGuard, resource: Resource, mode: LockMode| {
865            guard.acquire(resource.clone(), mode).map_err(|err| {
866                RedDBError::Query(format!(
867                    "could not acquire {mode:?} lock on {resource:?}: {err}; retry the statement"
868                ))
869            })
870        };
871        let mut guard = LockerGuard::new(runtime.inner.lock_manager.clone());
872        acquire(&mut guard, Resource::Global, global_mode)?;
873        // DDL takes X on its own collection, but every DDL also rewrites
874        // shared catalog state the collection lock does not cover: the
875        // system collections, the physical metadata and a full-store
876        // `persist()`. Serialise all DDL on the catalog (#2376).
877        if coll_mode == LockMode::Exclusive {
878            acquire(&mut guard, Resource::Catalog, LockMode::Exclusive)?;
879        }
880        for collection in collections_referenced(expr) {
881            acquire(&mut guard, Resource::Collection(collection), coll_mode)?;
882        }
883        Ok(Some(guard))
884    }
885}
886
887impl ReadFrame for StatementExecutionFrame {
888    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
889        SnapshotContext {
890            snapshot: self.snapshot.clone(),
891            manager: runtime.inner.transaction_state.snapshot_manager(),
892            own_xids: self.own_xids.clone(),
893            requires_index_fallback: self.requires_index_fallback,
894            serializable_reader: self.serializable_reader,
895        }
896    }
897
898    fn effective_scope(&self) -> Option<&str> {
899        self.effective_scope.as_deref()
900    }
901
902    fn identity(&self) -> Option<(&str, Role)> {
903        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
904    }
905
906    fn snapshot(&self) -> &Snapshot {
907        &self.snapshot
908    }
909
910    fn as_of_floor(&self) -> Option<Xid> {
911        self.as_of_floor
912    }
913
914    fn cache_key(&self) -> &str {
915        &self.cache_key
916    }
917
918    fn should_cache_result(&self) -> bool {
919        !self.is_volatile_query && self.cache_safe
920    }
921
922    fn required_privilege(&self) -> Privilege {
923        self.required_privilege
924    }
925
926    fn lock_intent(&self) -> LockIntent {
927        self.lock_intent
928    }
929
930    fn visible_collections(&self) -> Option<&HashSet<String>> {
931        self.visible_collections.as_ref()
932    }
933}
934
935/// Lightweight `ReadFrame` carrier used by AI command entry points
936/// (`SEARCH SIMILAR`, `SEARCH CONTEXT`, `ASK`).
937///
938/// Issue #119 calls this struct `EffectiveScope`. It bundles the
939/// `(tenant, structured principal, identity projection, role,
940/// visible_collections, snapshot)` tuple so
941/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
942/// instead of re-reading thread-locals at every call site.
943///
944/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
945/// from the per-statement thread-locals (identical to how
946/// `StatementExecutionFrame::build` derives them) and resolves
947/// `visible_collections` via the `AuthStore` cache.
948pub struct EffectiveScope {
949    pub(crate) captured_snapshot: Option<SnapshotContext>,
950    pub(crate) tenant: Option<String>,
951    pub(crate) identity: Option<(String, Role)>,
952    pub(crate) auth_principal: Option<crate::auth::UserId>,
953    pub(crate) snapshot: Snapshot,
954    pub(crate) visible_collections: Option<HashSet<String>>,
955}
956
957impl EffectiveScope {
958    /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
959    /// to gate LLM-backed NER calls behind `ai:ner:read`.
960    ///
961    /// Placeholder for now: always returns `false`. The auth engine's
962    /// capability matrix is future work; until it lands, every routed
963    /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
964    /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
965    /// Documented in code so the wire-up is a one-line change once
966    /// the auth engine learns capabilities.
967    pub fn has_capability(&self, _capability: &str) -> bool {
968        false
969    }
970}
971
972impl ReadFrame for EffectiveScope {
973    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
974        self.captured_snapshot
975            .clone()
976            .unwrap_or_else(|| SnapshotContext {
977                snapshot: self.snapshot.clone(),
978                manager: runtime.inner.transaction_state.snapshot_manager(),
979                own_xids: HashSet::new(),
980                requires_index_fallback: true,
981                serializable_reader: None,
982            })
983    }
984
985    fn effective_scope(&self) -> Option<&str> {
986        self.tenant.as_deref()
987    }
988    fn identity(&self) -> Option<(&str, Role)> {
989        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
990    }
991    fn snapshot(&self) -> &Snapshot {
992        &self.snapshot
993    }
994    fn as_of_floor(&self) -> Option<Xid> {
995        None
996    }
997    fn cache_key(&self) -> &str {
998        ""
999    }
1000    fn should_cache_result(&self) -> bool {
1001        false
1002    }
1003    fn required_privilege(&self) -> Privilege {
1004        Privilege::Read
1005    }
1006    fn lock_intent(&self) -> LockIntent {
1007        LockIntent::Shared
1008    }
1009    fn visible_collections(&self) -> Option<&HashSet<String>> {
1010        self.visible_collections.as_ref()
1011    }
1012}
1013
1014fn own_transaction_xids(
1015    ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
1016) -> HashSet<Xid> {
1017    let mut set = HashSet::new();
1018    if let Some(ctx) = ctx {
1019        set.insert(ctx.xid);
1020        for (_, sub) in &ctx.savepoints {
1021            set.insert(*sub);
1022        }
1023        for sub in &ctx.released_sub_xids {
1024            set.insert(*sub);
1025        }
1026    }
1027    set
1028}
1029
1030impl RedDBRuntime {
1031    /// Build the AI command `EffectiveScope` from the current
1032    /// statement thread-locals + auth store.
1033    ///
1034    /// Returns `None` for embedded callers (no auth store, no
1035    /// identity) — `AuthorizedSearch` treats `None` as deny-default.
1036    pub(crate) fn ai_scope(&self) -> EffectiveScope {
1037        let tenant = super::impl_core::current_tenant();
1038        let identity = super::impl_core::current_auth_identity();
1039        let auth_principal = super::execution_context::current_auth_principal();
1040        let captured_snapshot = super::execution_context::capture_current_snapshot();
1041        let snapshot = captured_snapshot.as_ref().map_or_else(
1042            || self.current_snapshot(),
1043            |context| context.snapshot.clone(),
1044        );
1045        let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
1046            (Some(store), Some((principal, role))) => {
1047                let collections = self.inner.db.store().list_collections();
1048                Some(store.visible_collections_for_scope(
1049                    tenant.as_deref(),
1050                    *role,
1051                    principal,
1052                    &collections,
1053                ))
1054            }
1055            _ => None,
1056        };
1057        EffectiveScope {
1058            captured_snapshot,
1059            tenant,
1060            identity,
1061            auth_principal,
1062            snapshot,
1063            visible_collections,
1064        }
1065    }
1066}
1067
1068/// Test fixtures for callers that need to drive `ReadFrame` without
1069/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
1070/// only leaks across module boundaries inside the crate.
1071#[cfg(test)]
1072pub(crate) mod test_support {
1073    use super::{LockIntent, Privilege, ReadFrame};
1074    use crate::auth::Role;
1075    use crate::storage::transaction::snapshot::{Snapshot, Xid};
1076    use std::collections::HashSet;
1077
1078    /// A `ReadFrame` impl with hand-set fields. Used by
1079    /// `authorized_search` tests to assert the deny-default and
1080    /// scope-trim behaviour without going through frame construction.
1081    pub(crate) struct FakeReadFrame {
1082        pub tenant: Option<String>,
1083        pub identity: Option<(String, Role)>,
1084        pub snapshot: Snapshot,
1085        pub visible: Option<HashSet<String>>,
1086    }
1087
1088    impl FakeReadFrame {
1089        pub(crate) fn without_scope() -> Self {
1090            Self {
1091                tenant: None,
1092                identity: None,
1093                snapshot: Snapshot {
1094                    xid: 0,
1095                    in_progress: HashSet::new(),
1096                },
1097                visible: None,
1098            }
1099        }
1100
1101        pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
1102            Self {
1103                tenant: Some("acme".to_string()),
1104                identity: Some(("alice".to_string(), Role::Read)),
1105                snapshot: Snapshot {
1106                    xid: 0,
1107                    in_progress: HashSet::new(),
1108                },
1109                visible: Some(visible),
1110            }
1111        }
1112    }
1113
1114    impl ReadFrame for FakeReadFrame {
1115        fn effective_scope(&self) -> Option<&str> {
1116            self.tenant.as_deref()
1117        }
1118        fn identity(&self) -> Option<(&str, Role)> {
1119            self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
1120        }
1121        fn snapshot(&self) -> &Snapshot {
1122            &self.snapshot
1123        }
1124        fn as_of_floor(&self) -> Option<Xid> {
1125            None
1126        }
1127        fn cache_key(&self) -> &str {
1128            ""
1129        }
1130        fn should_cache_result(&self) -> bool {
1131            false
1132        }
1133        fn required_privilege(&self) -> Privilege {
1134            Privilege::Read
1135        }
1136        fn lock_intent(&self) -> LockIntent {
1137            LockIntent::Shared
1138        }
1139        fn visible_collections(&self) -> Option<&HashSet<String>> {
1140            self.visible.as_ref()
1141        }
1142    }
1143}
1144
1145impl RedDBRuntime {
1146    /// Resolve the snapshot for the current statement, returning
1147    /// the snapshot itself and (when AS OF is in effect) the
1148    /// resolved xid floor. The floor is the same xid carried inside
1149    /// `Snapshot.xid` for AS OF reads — exposing it separately lets
1150    /// the `ReadFrame` Interface tell "live read" from "historical
1151    /// read" without inferring from `in_progress.is_empty()`.
1152    ///
1153    /// A live read nested inside an already-installed frame inherits that
1154    /// frame's snapshot instead of minting a fresh one — but only when it
1155    /// is genuinely *part of* that statement, which is exactly what
1156    /// [`StatementIdentity::Expr`] means. A CTE prelude, a
1157    /// materialized-view refresh, or an inline graph TVF is one statement
1158    /// and must observe one snapshot; re-minting under autocommit / Read
1159    /// Committed takes a new high-water mark, so the nodes and edges of a
1160    /// single graph TVF could otherwise be materialized under two
1161    /// different snapshots.
1162    ///
1163    /// A nested `Text` statement is a different thing entirely: a driver
1164    /// loop issuing statement after statement through `execute_query`.
1165    /// Those are sequential autocommit statements and each must see its
1166    /// predecessors' commits. Inheriting there froze the loop's view —
1167    /// `apply_batched` (`impl_migrations.rs`) drives `APPLY MIGRATION …
1168    /// BATCH N ROWS` by re-running the body with `LIMIT N` until fewer
1169    /// than `N` rows are affected, so a frozen snapshot re-selected the
1170    /// same rows forever and the migration never converged. Twenty-five
1171    /// rows at `BATCH 7` should finish in four iterations; it hung
1172    /// unbounded instead.
1173    ///
1174    /// `AS OF` still wins over both — it names its own snapshot explicitly.
1175    fn statement_snapshot(
1176        &self,
1177        query: &str,
1178        identity: StatementIdentity<'_>,
1179    ) -> RedDBResult<(Snapshot, Option<Xid>, SnapshotRetentionGuard)> {
1180        match peek_top_level_as_of_with_table(query) {
1181            Some((spec, Some(table))) => {
1182                if !table.starts_with("red_")
1183                    && !table.starts_with("red.")
1184                    && !self.vcs_is_versioned(&table)?
1185                {
1186                    return Err(RedDBError::InvalidConfig(format!(
1187                        "AS OF requires a versioned collection — \
1188                         `{table}` has not opted in. \
1189                         Call vcs.set_versioned(\"{table}\", true) first."
1190                    )));
1191                }
1192                let xid = self.vcs_resolve_as_of(spec)?;
1193                let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1194                    xid,
1195                    in_progress: HashSet::new(),
1196                });
1197                Ok((snapshot, Some(xid), retention))
1198            }
1199            Some((spec, None)) => {
1200                let xid = self.vcs_resolve_as_of(spec)?;
1201                let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1202                    xid,
1203                    in_progress: HashSet::new(),
1204                });
1205                Ok((snapshot, Some(xid), retention))
1206            }
1207            None => match (identity, capture_current_snapshot()) {
1208                (StatementIdentity::Expr(_), Some(outer)) => {
1209                    let (snapshot, retention) =
1210                        self.snapshot_manager().retain_snapshot(outer.snapshot);
1211                    Ok((snapshot, None, retention))
1212                }
1213                _ => {
1214                    let (snapshot, retention) = self
1215                        .inner
1216                        .transaction_state
1217                        .retained_current_snapshot(current_connection_id());
1218                    Ok((snapshot, None, retention))
1219                }
1220            },
1221        }
1222    }
1223
1224    fn result_cache_safe(&self, conn_id: u64) -> bool {
1225        let has_unsafe_xids = self
1226            .inner
1227            .transaction_state
1228            .snapshot_manager()
1229            .has_cache_unsafe_active_xids();
1230        let in_own_tx = self.inner.transaction_state.in_transaction(conn_id);
1231        !has_unsafe_xids && !in_own_tx
1232    }
1233}
1234
1235/// Whether a result's `engine` tag is one of the graph-analytics TVF
1236/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
1237/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
1238/// algorithm output that is cached regardless of row count.
1239fn is_graph_tvf_engine(engine: &str) -> bool {
1240    matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
1241}
1242
1243fn result_cache_key(query: &str) -> String {
1244    let tenant = current_tenant().unwrap_or_default();
1245    let auth = current_auth_identity()
1246        .map(|(user, role)| format!("{}|{:?}", user, role))
1247        .unwrap_or_default();
1248    if tenant.is_empty() && auth.is_empty() {
1249        query.to_string()
1250    } else {
1251        format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::api::RedDBOptions;
1259    use crate::runtime::impl_core::{
1260        clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1261        set_current_tenant,
1262    };
1263    use crate::runtime::RedDBRuntime;
1264
1265    fn fresh_runtime() -> RedDBRuntime {
1266        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1267    }
1268
1269    enum HeldFrame {
1270        Ordinary,
1271        Nested,
1272        Historical,
1273    }
1274
1275    fn verify_frame_survives_vacuum(delete: bool, kind: HeldFrame) {
1276        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
1277
1278        reset_thread_locals();
1279        clear_current_connection_id();
1280        let runtime = fresh_runtime();
1281        let execute = |connection, sql: &str| {
1282            set_current_connection_id(connection);
1283            let result = runtime.execute_query(sql);
1284            clear_current_connection_id();
1285            result.expect("valid frame retention fixture statement")
1286        };
1287        execute(1, "CREATE TABLE frame_rows (id TEXT, n INTEGER)");
1288        execute(1, "INSERT INTO frame_rows (id, n) VALUES ('row', 1)");
1289        let historical_xid = runtime.current_snapshot().xid;
1290        if matches!(kind, HeldFrame::Historical) {
1291            runtime
1292                .vcs_set_versioned("frame_rows", true)
1293                .expect("enable historical reads");
1294        }
1295        execute(2, "BEGIN");
1296        let query = if matches!(kind, HeldFrame::Historical) {
1297            format!("SELECT id, n FROM frame_rows AS OF SNAPSHOT {historical_xid}")
1298        } else {
1299            "SELECT id, n FROM frame_rows".to_string()
1300        };
1301        let frame = StatementExecutionFrame::build(&runtime, StatementIdentity::Text(&query))
1302            .expect("frame captures its snapshot");
1303        let frame = if matches!(kind, HeldFrame::Nested) {
1304            let guards = frame.install(&runtime);
1305            let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("frame_rows"));
1306            let nested = StatementExecutionFrame::build(&runtime, StatementIdentity::Expr(&expr))
1307                .expect("nested frame inherits the protected snapshot");
1308            drop(guards);
1309            drop(frame);
1310            nested
1311        } else {
1312            frame
1313        };
1314        // Pause after capture and before install/scan: GC must also protect
1315        // this gap, rather than starting retention only at TLS installation.
1316        execute(
1317            2,
1318            if delete {
1319                "DELETE FROM frame_rows WHERE id = 'row'"
1320            } else {
1321                "UPDATE frame_rows SET n = 2 WHERE id = 'row'"
1322            },
1323        );
1324        execute(2, "COMMIT");
1325        execute(1, "VACUUM frame_rows");
1326        {
1327            let _guards = frame.install(&runtime);
1328            let records = super::super::record_search::scan_runtime_table_source_records_limited(
1329                &runtime.db(),
1330                "frame_rows",
1331                None,
1332            )
1333            .expect("captured frame scan succeeds after vacuum");
1334            assert_eq!(
1335                records.len(),
1336                1,
1337                "vacuum reclaimed an active frame's visible row"
1338            );
1339            assert_eq!(
1340                records[0]
1341                    .get("n")
1342                    .expect("selected value exists")
1343                    .to_string(),
1344                "1"
1345            );
1346        }
1347        drop(frame);
1348        execute(1, "VACUUM frame_rows");
1349        let physical = runtime
1350            .db()
1351            .store()
1352            .get_collection("frame_rows")
1353            .expect("fixture collection exists")
1354            .query_all(|_| true)
1355            .len();
1356        assert_eq!(
1357            physical,
1358            usize::from(!delete),
1359            "completed frame leaked its retention pin"
1360        );
1361        clear_current_connection_id();
1362    }
1363
1364    #[test]
1365    fn autocommit_frame_retains_update_until_scan_completes() {
1366        verify_frame_survives_vacuum(false, HeldFrame::Ordinary);
1367    }
1368
1369    #[test]
1370    fn autocommit_frame_retains_delete_until_scan_completes() {
1371        verify_frame_survives_vacuum(true, HeldFrame::Ordinary);
1372    }
1373
1374    #[test]
1375    fn nested_frame_retains_history_after_parent_frame_drops() {
1376        verify_frame_survives_vacuum(false, HeldFrame::Nested);
1377    }
1378
1379    #[test]
1380    fn historical_frame_retains_history_until_scan_completes() {
1381        verify_frame_survives_vacuum(true, HeldFrame::Historical);
1382    }
1383
1384    /// Ensure thread-local state from a prior test can't leak into
1385    /// the next one — tests in the same binary share the thread.
1386    fn reset_thread_locals() {
1387        clear_current_tenant();
1388        clear_current_auth_identity();
1389    }
1390
1391    #[test]
1392    fn autocommit_select_takes_live_snapshot() {
1393        reset_thread_locals();
1394        let rt = fresh_runtime();
1395        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1396            .expect("frame builds for SELECT 1");
1397
1398        // The inclusive bound excludes the next unallocated writer xid.
1399        // A pristine manager therefore has a valid empty snapshot at zero.
1400        let f: &dyn ReadFrame = &frame;
1401        assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1402        assert_eq!(f.snapshot().xid, rt.snapshot_manager().peek_next_xid() - 1);
1403        assert!(!f.snapshot().sees(rt.snapshot_manager().peek_next_xid(), 0));
1404    }
1405
1406    #[test]
1407    fn autocommit_snapshot_does_not_lose_rows_after_index_publication() {
1408        reset_thread_locals();
1409        let rt = fresh_runtime();
1410        rt.execute_query("CREATE TABLE frame_index_race (id INT PRIMARY KEY, status TEXT)")
1411            .expect("create fixture");
1412        rt.execute_query("INSERT INTO frame_index_race (id, status) VALUES (1, 'old'), (2, 'old')")
1413            .expect("insert fixture");
1414        rt.execute_query("CREATE INDEX frame_status_hash ON frame_index_race (status) USING HASH")
1415            .expect("create index");
1416        let sql = "SELECT id FROM frame_index_race WHERE status = 'old'";
1417        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1418            .expect("capture autocommit snapshot");
1419        rt.execute_query("UPDATE frame_index_race SET status = 'new' WHERE id = 1")
1420            .expect("publish changed index entry");
1421        let reddb_rql::ast::QueryExpr::Table(query) =
1422            reddb_rql::modes::parse_multi(sql).expect("parse")
1423        else {
1424            panic!("expected table query");
1425        };
1426        let _guards = frame.install(&rt);
1427        let records =
1428            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1429                &rt.db(),
1430                &query,
1431                Some(rt.index_store_ref()),
1432            )
1433            .expect("read captured snapshot");
1434        let mut ids = records
1435            .iter()
1436            .map(|record| record.get("id").expect("id").to_string())
1437            .collect::<Vec<_>>();
1438        ids.sort();
1439        assert_eq!(
1440            ids,
1441            ["1", "2"],
1442            "current index omitted a visible historical row"
1443        );
1444    }
1445
1446    #[test]
1447    fn captured_dml_snapshot_finds_rows_after_index_publication() {
1448        reset_thread_locals();
1449        let rt = fresh_runtime();
1450        rt.execute_query("CREATE TABLE frame_dml_race (id INT PRIMARY KEY, status TEXT)")
1451            .expect("create fixture");
1452        rt.execute_query("INSERT INTO frame_dml_race (id, status) VALUES (1, 'old'), (2, 'old')")
1453            .expect("insert fixture");
1454        rt.execute_query("CREATE INDEX frame_dml_status ON frame_dml_race (status) USING HASH")
1455            .expect("create index");
1456        let sql = "DELETE FROM frame_dml_race WHERE status = 'old'";
1457        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1458            .expect("capture DML snapshot");
1459        rt.execute_query("UPDATE frame_dml_race SET status = 'new' WHERE id = 1")
1460            .expect("publish new indexed value");
1461        let reddb_rql::ast::QueryExpr::Table(query) =
1462            reddb_rql::modes::parse_multi("SELECT id FROM frame_dml_race WHERE status = 'old'")
1463                .expect("parse filter")
1464        else {
1465            panic!("expected table query")
1466        };
1467        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1468        let _guards = frame.install(&rt);
1469        let ids = super::super::dml_target_scan::DmlTargetScan::new(
1470            &rt,
1471            "frame_dml_race",
1472            Some(&filter),
1473            None,
1474        )
1475        .find_target_ids()
1476        .expect("find DML targets");
1477        assert_eq!(
1478            ids.len(),
1479            2,
1480            "DML target scan must discover both snapshot-visible rows"
1481        );
1482    }
1483
1484    #[test]
1485    fn captured_snapshot_survives_indexed_update_and_delete_for_range_queries() {
1486        reset_thread_locals();
1487        let rt = fresh_runtime();
1488        rt.execute_query("CREATE TABLE frame_range_race (id INT PRIMARY KEY, score INT)")
1489            .expect("create fixture");
1490        rt.execute_query("INSERT INTO frame_range_race (id, score) VALUES (1, 10), (2, 20)")
1491            .expect("insert fixture");
1492        rt.execute_query("CREATE INDEX frame_score_range ON frame_range_race (score)")
1493            .expect("create index");
1494        let sql = "SELECT id FROM frame_range_race WHERE score >= 10 AND score <= 20 ORDER BY id";
1495        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1496            .expect("capture read snapshot");
1497        rt.execute_query("UPDATE frame_range_race SET score = 99 WHERE id = 1")
1498            .expect("update row");
1499        rt.execute_query("DELETE FROM frame_range_race WHERE id = 2")
1500            .expect("delete row");
1501        let reddb_rql::ast::QueryExpr::Table(query) =
1502            reddb_rql::modes::parse_multi(sql).expect("parse")
1503        else {
1504            panic!("expected table query");
1505        };
1506        let _guards = frame.install(&rt);
1507        let records =
1508            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1509                &rt.db(),
1510                &query,
1511                Some(rt.index_store_ref()),
1512            )
1513            .expect("read captured snapshot");
1514        let ids = records
1515            .iter()
1516            .map(|record| record.get("id").expect("id").to_string())
1517            .collect::<Vec<_>>();
1518        assert_eq!(ids, ["1", "2"]);
1519    }
1520
1521    #[test]
1522    fn dml_target_scan_does_not_retry_inside_stored_function_budget() {
1523        use std::sync::atomic::{AtomicUsize, Ordering};
1524        use std::sync::Arc;
1525        reset_thread_locals();
1526        let rt = fresh_runtime();
1527        rt.execute_query("CREATE TABLE dml_budget_guard (id INT, status TEXT)")
1528            .expect("create fixture");
1529        rt.execute_query("INSERT INTO dml_budget_guard (id, status) VALUES (1, 'old')")
1530            .expect("insert fixture");
1531        rt.execute_query("CREATE INDEX dml_budget_status ON dml_budget_guard (status) USING HASH")
1532            .expect("create index");
1533        let frame = StatementExecutionFrame::build(
1534            &rt,
1535            StatementIdentity::Text("DELETE FROM dml_budget_guard WHERE status = 'old'"),
1536        )
1537        .expect("capture snapshot");
1538        let reddb_rql::ast::QueryExpr::Table(query) =
1539            reddb_rql::modes::parse_multi("SELECT id FROM dml_budget_guard WHERE status = 'old'")
1540                .expect("parse filter")
1541        else {
1542            panic!("expected table query")
1543        };
1544        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1545        let calls = Arc::new(AtomicUsize::new(0));
1546        let observed = Arc::clone(&calls);
1547        *rt.index_store_ref().mutation_hook.lock() = Some(Arc::new(move |collection, phase| {
1548            if collection == "dml_budget_guard"
1549                && phase == super::super::index_store::MutationTestPhase::DmlIndexLookupStarted
1550            {
1551                observed.fetch_add(1, Ordering::SeqCst);
1552            }
1553        }));
1554        let _budget = super::super::function_budget::Scope::enter(1_000, 10_000)
1555            .expect("stored function budget");
1556        let _guards = frame.install(&rt);
1557        let ids = super::super::dml_target_scan::DmlTargetScan::new(
1558            &rt,
1559            "dml_budget_guard",
1560            Some(&filter),
1561            None,
1562        )
1563        .find_target_ids()
1564        .expect("find DML targets");
1565        assert_eq!(ids.len(), 1);
1566        assert_eq!(
1567            calls.load(Ordering::SeqCst),
1568            0,
1569            "bounded stored functions must not retry indexed discovery"
1570        );
1571        *rt.index_store_ref().mutation_hook.lock() = None;
1572    }
1573
1574    #[test]
1575    fn dml_target_selection_preserves_snapshot_when_publication_races() {
1576        use std::sync::{mpsc, Arc, Mutex};
1577        use std::time::Duration;
1578        reset_thread_locals();
1579        let rt = fresh_runtime();
1580        rt.execute_query("CREATE TABLE dml_lookup_race (id INT PRIMARY KEY, status TEXT)")
1581            .expect("create fixture");
1582        rt.execute_query("INSERT INTO dml_lookup_race (id, status) VALUES (1, 'old'), (2, 'old')")
1583            .expect("insert fixture");
1584        rt.execute_query("CREATE INDEX dml_lookup_status ON dml_lookup_race (status) USING HASH")
1585            .expect("create index");
1586        let frame = StatementExecutionFrame::build(
1587            &rt,
1588            StatementIdentity::Text("DELETE FROM dml_lookup_race WHERE status = 'old'"),
1589        )
1590        .expect("capture snapshot");
1591        let reddb_rql::ast::QueryExpr::Table(query) =
1592            reddb_rql::modes::parse_multi("SELECT id FROM dml_lookup_race WHERE status = 'old'")
1593                .expect("parse filter")
1594        else {
1595            panic!("expected table query")
1596        };
1597        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1598        let (started_tx, started_rx) = mpsc::channel();
1599        let (published_tx, published_rx) = mpsc::channel();
1600        let published_rx = Mutex::new(published_rx);
1601        let first = std::sync::atomic::AtomicBool::new(true);
1602        *rt.index_store_ref().mutation_hook.lock() = Some(Arc::new(move |collection, phase| {
1603            if collection == "dml_lookup_race"
1604                && phase == super::super::index_store::MutationTestPhase::DmlTargetSelectionStarted
1605                && first.swap(false, std::sync::atomic::Ordering::SeqCst)
1606            {
1607                started_tx.send(()).expect("notify writer");
1608                published_rx
1609                    .lock()
1610                    .unwrap()
1611                    .recv_timeout(Duration::from_secs(10))
1612                    .expect("writer published");
1613            }
1614        }));
1615        std::thread::scope(|scope| {
1616            let writer_rt = &rt;
1617            let writer = scope.spawn(move || {
1618                started_rx
1619                    .recv_timeout(Duration::from_secs(10))
1620                    .expect("lookup started");
1621                writer_rt
1622                    .execute_query("UPDATE dml_lookup_race SET status = 'new' WHERE id = 1")
1623                    .expect("publish update");
1624                published_tx.send(()).expect("release lookup");
1625            });
1626            let limits = super::super::resource_limits::ResourceLimits {
1627                max_query_duration: Some(Duration::from_secs(30)),
1628                ..Default::default()
1629            };
1630            let slots = std::sync::atomic::AtomicU64::new(0);
1631            let _budget = super::super::query_budget::Scope::enter(&limits, &slots)
1632                .expect("query deadline scope");
1633            let _guards = frame.install(&rt);
1634            let ids = super::super::dml_target_scan::DmlTargetScan::new(
1635                &rt,
1636                "dml_lookup_race",
1637                Some(&filter),
1638                None,
1639            )
1640            .find_target_ids()
1641            .expect("find DML targets");
1642            writer.join().expect("writer thread");
1643            assert_eq!(
1644                ids.len(),
1645                2,
1646                "racing publication must not omit a snapshot-visible row"
1647            );
1648        });
1649        *rt.index_store_ref().mutation_hook.lock() = None;
1650    }
1651
1652    #[test]
1653    fn replica_replay_preserves_snapshots_across_insert_update_and_delete() {
1654        use crate::replication::cdc::{change_record_from_entity, ChangeOperation};
1655        use crate::replication::logical::{ApplyMode, LogicalChangeApplier};
1656        reset_thread_locals();
1657        let source = fresh_runtime();
1658        let replica = fresh_runtime();
1659        for rt in [&source, &replica] {
1660            rt.execute_query("CREATE TABLE replica_snapshot (id INT, status TEXT)")
1661                .expect("schema");
1662        }
1663        replica
1664            .execute_query(
1665                "CREATE INDEX replica_snapshot_status ON replica_snapshot (status) USING HASH",
1666            )
1667            .expect("index");
1668        let sql = "SELECT id FROM replica_snapshot WHERE status = 'old'";
1669        let capture = || {
1670            StatementExecutionFrame::build(&replica, StatementIdentity::Text(sql)).expect("capture")
1671        };
1672        let read = |frame: &StatementExecutionFrame| {
1673            let reddb_rql::ast::QueryExpr::Table(query) =
1674                reddb_rql::modes::parse_multi(sql).expect("parse")
1675            else {
1676                panic!("table")
1677            };
1678            let _guards = frame.install(&replica);
1679            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1680                &replica.db(),
1681                &query,
1682                Some(replica.index_store_ref()),
1683            )
1684            .expect("snapshot read")
1685            .len()
1686        };
1687        let empty = capture();
1688        source
1689            .execute_query("INSERT INTO replica_snapshot (id, status) VALUES (1, 'old')")
1690            .expect("insert");
1691        let applier = LogicalChangeApplier::new(0);
1692        let replay = |lsn, op| {
1693            let entity = source
1694                .db()
1695                .store()
1696                .get_collection("replica_snapshot")
1697                .unwrap()
1698                .query_all(|entity| entity.xmax == 0)
1699                .pop()
1700                .unwrap();
1701            applier
1702                .apply(
1703                    &replica.db(),
1704                    &change_record_from_entity(
1705                        lsn,
1706                        1,
1707                        op,
1708                        "replica_snapshot",
1709                        "table",
1710                        &entity,
1711                        12,
1712                        None,
1713                    ),
1714                    ApplyMode::Replica,
1715                )
1716                .expect("replay");
1717        };
1718        replay(1, ChangeOperation::Insert);
1719        assert_eq!(
1720            read(&empty),
1721            0,
1722            "replay must not backdate an insert into an earlier snapshot"
1723        );
1724        let inserted = capture();
1725        source
1726            .execute_query("UPDATE replica_snapshot SET status = 'new' WHERE id = 1")
1727            .expect("update");
1728        replay(2, ChangeOperation::Update);
1729        assert_eq!(read(&inserted), 1, "old version remains visible");
1730        assert_eq!(read(&capture()), 0, "fresh snapshot uses the updated index");
1731        // A snapshot of all rows must retain the deleted version as well.
1732        let all_sql = "SELECT id FROM replica_snapshot";
1733        let before_delete =
1734            StatementExecutionFrame::build(&replica, StatementIdentity::Text(all_sql)).unwrap();
1735        replay(3, ChangeOperation::Delete);
1736        let reddb_rql::ast::QueryExpr::Table(query) =
1737            reddb_rql::modes::parse_multi(all_sql).unwrap()
1738        else {
1739            panic!("table")
1740        };
1741        {
1742            let _guards = before_delete.install(&replica);
1743            assert_eq!(
1744                super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1745                    &replica.db(),
1746                    &query,
1747                    Some(replica.index_store_ref())
1748                )
1749                .unwrap()
1750                .len(),
1751                1
1752            );
1753        }
1754        assert_eq!(
1755            replica.execute_query(all_sql).unwrap().result.records.len(),
1756            0
1757        );
1758    }
1759
1760    #[test]
1761    fn autocommit_index_reads_keep_old_rows_while_another_transaction_is_open() {
1762        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
1763        reset_thread_locals();
1764        let rt = fresh_runtime();
1765        let execute = |connection, sql: &str| {
1766            set_current_connection_id(connection);
1767            let result = rt.execute_query(sql);
1768            clear_current_connection_id();
1769            result.expect("statement")
1770        };
1771        execute(1, "CREATE TABLE active_writer_rows (id INT, status TEXT)");
1772        execute(
1773            1,
1774            "INSERT INTO active_writer_rows (id, status) VALUES (1, 'old')",
1775        );
1776        execute(
1777            1,
1778            "CREATE INDEX active_writer_status ON active_writer_rows (status) USING HASH",
1779        );
1780        execute(2, "BEGIN");
1781        execute(
1782            2,
1783            "UPDATE active_writer_rows SET status = 'new' WHERE id = 1",
1784        );
1785        assert_eq!(
1786            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'old'")
1787                .result
1788                .records
1789                .len(),
1790            1
1791        );
1792        assert_eq!(
1793            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'new'")
1794                .result
1795                .records
1796                .len(),
1797            0
1798        );
1799        execute(2, "ROLLBACK");
1800        assert_eq!(
1801            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'old'")
1802                .result
1803                .records
1804                .len(),
1805            1
1806        );
1807    }
1808
1809    #[test]
1810    fn frame_captures_identity_and_scope() {
1811        reset_thread_locals();
1812        set_current_tenant("acme".to_string());
1813        set_current_auth_identity("alice".to_string(), Role::Write);
1814
1815        let rt = fresh_runtime();
1816        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1817            .expect("frame builds");
1818        let f: &dyn ReadFrame = &frame;
1819
1820        assert_eq!(f.effective_scope(), Some("acme"));
1821        let id = f.identity().expect("identity captured");
1822        assert_eq!(id.0, "alice");
1823        assert!(matches!(id.1, Role::Write));
1824
1825        // Cache key mixes scope + identity so two callers under
1826        // different tenants never share a cache slot.
1827        assert!(
1828            f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1829            "cache key folds in scope + identity, got {:?}",
1830            f.cache_key()
1831        );
1832
1833        reset_thread_locals();
1834    }
1835
1836    #[test]
1837    fn as_of_rejects_non_versioned_user_collection() {
1838        reset_thread_locals();
1839        let rt = fresh_runtime();
1840
1841        // `not_versioned` is a plain user collection — the frame
1842        // builder must reject AS OF until the caller opts in via
1843        // `vcs.set_versioned`.
1844        let err = match StatementExecutionFrame::build(
1845            &rt,
1846            StatementIdentity::Text("SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'"),
1847        ) {
1848            Err(e) => e,
1849            Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1850        };
1851
1852        let msg = format!("{err}");
1853        assert!(
1854            msg.contains("AS OF requires a versioned collection"),
1855            "expected AS OF rejection, got: {msg}"
1856        );
1857    }
1858
1859    /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1860    ///
1861    /// Sets a tenant + identity via the public thread-local API the
1862    /// runtime uses for ambient scope, drives a real `SELECT` through
1863    /// `execute_query`, then inspects the result cache that the SELECT
1864    /// path populates via `frame.cache_key()`. The key only carries
1865    /// the tenant + identity *because* it was built through the frame —
1866    /// reverting the wiring to inline `current_tenant()` /
1867    /// `current_auth_identity()` reads would still pass this test, but
1868    /// dropping the frame entirely (so the SELECT path stopped touching
1869    /// `cache_key`) would break it.
1870    #[test]
1871    fn select_path_routes_through_frame_cache_key() {
1872        reset_thread_locals();
1873        set_current_tenant("acme".to_string());
1874        set_current_auth_identity("alice".to_string(), Role::Read);
1875
1876        let rt = fresh_runtime();
1877        let result = rt
1878            .execute_query("SELECT 1")
1879            .expect("SELECT 1 executes under tenant=acme/identity=alice");
1880        assert_eq!(result.statement_type, "select");
1881
1882        // The textual SELECT path builds a frame and
1883        // writes its result through `frame.cache_key()`. That key folds
1884        // tenant + identity in via `result_cache_key`, so finding "acme"
1885        // and "alice" inside any cached key proves the frame was the
1886        // seam used.
1887        let cache = rt.inner.result_blob_entries.read();
1888        let any_keyed_with_scope = cache
1889            .0
1890            .keys()
1891            .any(|k| k.contains("acme") && k.contains("alice"));
1892        assert!(
1893            any_keyed_with_scope,
1894            "expected at least one result-cache key carrying tenant+identity, \
1895             got keys: {:?}",
1896            cache.0.keys().collect::<Vec<_>>()
1897        );
1898
1899        reset_thread_locals();
1900    }
1901
1902    /// A SELECT that calls a volatile builtin (here:
1903    /// `pg_advisory_unlock`, the volatile token the runtime currently
1904    /// recognises in `query_has_volatile_builtin`) must NOT populate
1905    /// the result cache. Any caller hitting the cache after this would
1906    /// see a stale answer for an inherently-volatile query, so the
1907    /// SELECT path gates writes through `frame.should_cache_result()`.
1908    ///
1909    /// Deletion test: removing `ReadFrame::should_cache_result`, or
1910    /// reverting the SELECT path to skip its safety gate, would let
1911    /// the result cache silently absorb this statement and break the
1912    /// assertion below.
1913    #[test]
1914    fn volatile_select_does_not_populate_result_cache() {
1915        reset_thread_locals();
1916        let rt = fresh_runtime();
1917
1918        // Frame-level invariant: the volatile-builtin signal collapses
1919        // `should_cache_result` to false even for an autocommit /
1920        // out-of-tx connection.
1921        let frame = StatementExecutionFrame::build(
1922            &rt,
1923            StatementIdentity::Text("SELECT pg_advisory_unlock(1)"),
1924        )
1925        .expect("frame");
1926        let f: &dyn ReadFrame = &frame;
1927        assert!(
1928            !f.should_cache_result(),
1929            "volatile builtin must disable result-cache safety"
1930        );
1931
1932        // End-to-end: drive the volatile SELECT through `execute_query`
1933        // and confirm no entry was stamped under its cache key. Other
1934        // entries from prior tests sharing the binary may exist, so we
1935        // assert specifically on this query's key.
1936        let _ = rt
1937            .execute_query("SELECT pg_advisory_unlock(1)")
1938            .expect("volatile SELECT executes");
1939        let cache = rt.inner.result_blob_entries.read();
1940        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1941        assert!(
1942            !cache.0.contains_key(&key),
1943            "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1944            cache.0.keys().collect::<Vec<_>>()
1945        );
1946
1947        reset_thread_locals();
1948    }
1949
1950    #[test]
1951    fn result_cache_populates_blob_path() {
1952        reset_thread_locals();
1953        let rt = fresh_runtime();
1954        let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1955        assert_eq!(result.statement_type, "select");
1956
1957        let key = result_cache_key("SELECT 1");
1958        assert!(
1959            rt.inner
1960                .result_blob_cache
1961                .get("runtime.result_cache", &key)
1962                .is_some(),
1963            "blob backend should stamp the Blob Cache path"
1964        );
1965        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1966    }
1967
1968    #[test]
1969    fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1970        reset_thread_locals();
1971        let rt = fresh_runtime();
1972        let _ = rt
1973            .execute_query("SELECT pg_advisory_unlock(1)")
1974            .expect("volatile SELECT executes");
1975        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1976        assert!(
1977            rt.inner
1978                .result_blob_cache
1979                .get("runtime.result_cache", &key)
1980                .is_none(),
1981            "volatile SELECT must not populate blob result cache"
1982        );
1983        assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1984    }
1985
1986    #[test]
1987    fn as_of_on_red_collection_records_floor() {
1988        reset_thread_locals();
1989        let rt = fresh_runtime();
1990
1991        // `red_*` collections always allow AS OF. The frame should
1992        // resolve to a concrete xid and surface it via the Interface.
1993        let frame = StatementExecutionFrame::build(
1994            &rt,
1995            StatementIdentity::Text("SELECT * FROM red_commits AS OF SNAPSHOT 1"),
1996        )
1997        .expect("AS OF SNAPSHOT 1 on red_commits resolves");
1998
1999        let f: &dyn ReadFrame = &frame;
2000        assert_eq!(
2001            f.as_of_floor(),
2002            Some(1),
2003            "AS OF SNAPSHOT 1 records xid=1 as the floor"
2004        );
2005        assert_eq!(f.snapshot().xid, 1);
2006        assert!(
2007            f.snapshot().in_progress.is_empty(),
2008            "historical reads have no in-progress set"
2009        );
2010    }
2011
2012    /// The frame classifies common SQL prefixes into the coarse
2013    /// `Privilege` / `LockIntent` buckets at build time. This test
2014    /// pins the mapping so a regression that silently re-routes
2015    /// (e.g. INSERT classified as Read) surfaces here, not at a
2016    /// downstream privilege gate.
2017    #[test]
2018    fn frame_classifies_privilege_and_lock_intent_from_prefix() {
2019        reset_thread_locals();
2020        let rt = fresh_runtime();
2021
2022        let cases = [
2023            ("SELECT 1", Privilege::Read, LockIntent::Shared),
2024            ("LIST KV settings", Privilege::Read, LockIntent::Shared),
2025            (
2026                "KV GET settings.feature",
2027                Privilege::Read,
2028                LockIntent::Shared,
2029            ),
2030            ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
2031            (
2032                "INSERT INTO t (id) VALUES (1)",
2033                Privilege::Write,
2034                LockIntent::Exclusive,
2035            ),
2036            (
2037                "KV PUT settings.feature = 'on'",
2038                Privilege::Write,
2039                LockIntent::Exclusive,
2040            ),
2041            (
2042                "VAULT PUT secrets.api = 'x'",
2043                Privilege::Write,
2044                LockIntent::Exclusive,
2045            ),
2046            (
2047                "UPDATE t SET x = 1 WHERE id = 1",
2048                Privilege::Write,
2049                LockIntent::Exclusive,
2050            ),
2051            (
2052                "DELETE FROM t WHERE id = 1",
2053                Privilege::Write,
2054                LockIntent::Exclusive,
2055            ),
2056            (
2057                "CREATE TABLE foo (id INT)",
2058                Privilege::Write,
2059                LockIntent::Exclusive,
2060            ),
2061            ("BEGIN", Privilege::None, LockIntent::None),
2062            ("COMMIT", Privilege::None, LockIntent::None),
2063            ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
2064        ];
2065
2066        for (q, want_priv, want_lock) in cases {
2067            let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(q))
2068                .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
2069            let f: &dyn ReadFrame = &frame;
2070            assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
2071            assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
2072        }
2073    }
2074
2075    /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
2076    /// driven through `execute_query` under an identity whose role
2077    /// doesn't satisfy the frame's coarse `Read` privilege gets
2078    /// denied with the frame's signal.
2079    ///
2080    /// We test the gate by classifying an INSERT (which the frame
2081    /// reports as `Privilege::Write`) under `Role::Read` — the only
2082    /// pair the legacy fallback would also reject, but here the
2083    /// rejection comes through `frame.check_query_privilege` BEFORE
2084    /// the parsed-expression walker runs. Removing
2085    /// `required_privilege` (or the `is_satisfied_by` consult inside
2086    /// `check_query_privilege`) would force the deny path back to the
2087    /// inline `RedDBRuntime::check_query_privilege` walker — but the
2088    /// auth_store gate up there is bypassed when no auth_store is
2089    /// wired (embedded test mode), so this test would FLIP from
2090    /// denied to permitted and break the assertion below.
2091    #[test]
2092    fn insert_under_read_role_denied_via_frame_privilege() {
2093        reset_thread_locals();
2094        set_current_auth_identity("alice".to_string(), Role::Read);
2095
2096        let rt = fresh_runtime();
2097        // Bypass parser by reaching into the frame directly: the
2098        // frame derives privilege from the SQL prefix without
2099        // needing an auth_store wired up. Driving end-to-end via
2100        // `execute_query` would also reject (no table `t`), but for
2101        // a different reason — we want to pin the privilege seam.
2102        let frame = StatementExecutionFrame::build(
2103            &rt,
2104            StatementIdentity::Text("INSERT INTO t (id) VALUES (1)"),
2105        )
2106        .expect("frame builds for INSERT");
2107        let f: &dyn ReadFrame = &frame;
2108        assert_eq!(
2109            f.required_privilege(),
2110            Privilege::Write,
2111            "INSERT classified as Write"
2112        );
2113        let id = f.identity().expect("identity captured");
2114        assert!(
2115            !f.required_privilege().is_satisfied_by(id.1),
2116            "Role::Read does not satisfy Privilege::Write — frame must deny"
2117        );
2118
2119        // End-to-end: the frame's `check_query_privilege` sees the
2120        // (Read role, Write privilege) mismatch and denies before
2121        // dispatch. We drive a synthetic `QueryExpr::Table` because
2122        // the SELECT/INSERT parser would happen to also fail, and we
2123        // want the failure to come from the privilege seam.
2124        use reddb_rql::ast::{QueryExpr, TableQuery};
2125        let expr = QueryExpr::Table(TableQuery::new("t"));
2126        let err = frame
2127            .check_query_privilege(&rt, &expr)
2128            .expect_err("denied via frame's coarse privilege gate");
2129        let msg = format!("{err}");
2130        assert!(
2131            msg.contains("permission denied") && msg.contains("Write"),
2132            "expected frame-level Write deny, got: {msg}"
2133        );
2134
2135        reset_thread_locals();
2136    }
2137
2138    /// End-to-end proof that the frame-owned row-buffer arena (#885) is
2139    /// wired into the SELECT path and produces observable results
2140    /// byte-identical to the per-request-allocation baseline.
2141    ///
2142    /// A table with more rows than the streaming high-water mark
2143    /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
2144    /// path to assemble many chunks, each leasing/recycling the frame
2145    /// arena's single chunk buffer. Driving it through `execute_query`
2146    /// (which builds a `StatementExecutionFrame` and lends its arena)
2147    /// must return every inserted row, in order — exactly what the
2148    /// allocate-per-chunk path returned. A bug in the arena wiring
2149    /// (dropped rows, bled rows, mis-ordering) would surface here.
2150    #[test]
2151    fn large_select_through_frame_arena_returns_all_rows_in_order() {
2152        reset_thread_locals();
2153        let rt = fresh_runtime();
2154        rt.execute_query("CREATE TABLE big (id INT)")
2155            .expect("create table");
2156
2157        // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
2158        // spans multiple chunks and the arena buffer is reused.
2159        const N: usize = 2_500;
2160        for start in (0..N).step_by(250) {
2161            let end = (start + 250).min(N);
2162            let values = (start..end)
2163                .map(|i| format!("({i})"))
2164                .collect::<Vec<_>>()
2165                .join(", ");
2166            rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
2167                .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
2168        }
2169
2170        let result = rt
2171            .execute_query("SELECT id FROM big ORDER BY id")
2172            .expect("large SELECT executes through the frame arena path");
2173        assert_eq!(result.statement_type, "select");
2174        assert_eq!(
2175            result.result.records.len(),
2176            N,
2177            "every inserted row streams back through the arena-backed channel"
2178        );
2179        for (i, record) in result.result.records.iter().enumerate() {
2180            assert_eq!(
2181                record.get("id"),
2182                Some(&reddb_types::Value::Integer(i as i64)),
2183                "row {i} is byte-identical to the per-request-allocation baseline"
2184            );
2185        }
2186
2187        reset_thread_locals();
2188    }
2189
2190    /// Transport adapters may decode their wire-specific parameter value
2191    /// shapes, but SQL parsing/binding must stay behind the runtime's
2192    /// statement entrypoint. This pins the deeper seam introduced for
2193    /// parameterized query execution. HTTP, JSON-RPC, RedWire, and gRPC hand
2194    /// a `QueryRequest` to the Request module, as does PG wire through the
2195    /// shared prepared registry. All paths install a real
2196    /// `StatementExecutionFrame` before dispatch.
2197    ///
2198    /// The Request transports reach the runtime entrypoint one layer deeper:
2199    /// `QueryRequestExecutor` calls `execute_query_with_params` on their
2200    /// behalf. Their delegation target is the Request seam, so that is what
2201    /// is pinned here — the "no binding in the adapter" half is unchanged
2202    /// for all.
2203    #[test]
2204    fn parameterized_transport_adapters_delegate_binding_to_runtime() {
2205        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2206        let adapters = [
2207            ("src/server/handlers_query.rs", "QueryRequestExecutor"),
2208            ("src/rpc_stdio.rs", "QueryRequestExecutor"),
2209            ("src/wire/redwire/session.rs", "QueryRequestExecutor"),
2210            ("src/wire/postgres/server.rs", "QueryRequestExecutor"),
2211            ("src/grpc.rs", "QueryRequestExecutor"),
2212        ];
2213
2214        for (relative, delegation) in adapters {
2215            let path = manifest_dir.join(relative);
2216            let text = std::fs::read_to_string(&path)
2217                .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
2218            assert!(
2219                text.contains(delegation),
2220                "{relative} should delegate parameterized query execution through {delegation}"
2221            );
2222            assert!(
2223                !text.contains("user_params::bind"),
2224                "{relative} must not bind SQL params in the transport adapter"
2225            );
2226        }
2227
2228        // The Request module is the only place RedWire's delegation may
2229        // bottom out: it must still reach the runtime entrypoint itself.
2230        let seam = std::fs::read_to_string(manifest_dir.join("src/runtime/query_request.rs"))
2231            .expect("read src/runtime/query_request.rs");
2232        assert!(
2233            seam.contains("execute_query_with_params"),
2234            "the Request module must execute text SQL through the runtime entrypoint"
2235        );
2236    }
2237
2238    /// Deletion-test for `ReadFrame::lock_intent`: a transaction
2239    /// control statement carries `LockIntent::None` and the
2240    /// `acquire_intent_locks` path returns `None` without consulting
2241    /// `intent_lock_modes_for`. Removing the method (or its consult
2242    /// site in `acquire_intent_locks`) would force the lock-mode
2243    /// helper to walk a fabricated parsed expression to reach the
2244    /// same conclusion — but the assertion that no guard is allocated
2245    /// for a `BEGIN` frame would still hold, so we additionally pin
2246    /// the classifier mapping above to make the deletion observable.
2247    #[test]
2248    fn control_statement_skips_intent_locks_via_frame() {
2249        reset_thread_locals();
2250        let rt = fresh_runtime();
2251
2252        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("BEGIN"))
2253            .expect("frame builds for BEGIN");
2254        let f: &dyn ReadFrame = &frame;
2255        assert_eq!(f.lock_intent(), LockIntent::None);
2256
2257        // Drive `acquire_intent_locks` against a fabricated SELECT
2258        // expression that WOULD normally yield `(IS, IS)`; the frame's
2259        // `lock_intent() == None` short-circuit must still suppress
2260        // the guard.
2261        use reddb_rql::ast::{QueryExpr, TableQuery};
2262        let expr = QueryExpr::Table(TableQuery::new("t"));
2263        let guard = frame
2264            .acquire_intent_locks(&rt, &expr)
2265            .expect("no lock is attempted");
2266        assert!(
2267            guard.is_none(),
2268            "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
2269        );
2270    }
2271
2272    /// Nested sub-execution inside one statement (CTE prelude,
2273    /// materialized-view refresh, inline graph TVF) must read the
2274    /// enclosing statement's snapshot. Autocommit is the case that used
2275    /// to break: `current_snapshot()` mints a fresh high-water mark per
2276    /// call, so an inline graph TVF could materialize its nodes and its
2277    /// edges under two different snapshots within one statement.
2278    #[test]
2279    fn nested_frame_inherits_outer_statement_snapshot() {
2280        reset_thread_locals();
2281        let rt = fresh_runtime();
2282        rt.execute_query("CREATE TABLE nested_snap (id INT)")
2283            .expect("create table");
2284
2285        let outer = StatementExecutionFrame::build(
2286            &rt,
2287            StatementIdentity::Text("SELECT * FROM nested_snap"),
2288        )
2289        .expect("outer frame builds");
2290        let _outer_guards = outer.install(&rt);
2291
2292        // A write between the two builds moves the high-water mark, so a
2293        // re-minting nested frame would land on a different xid.
2294        rt.execute_query("INSERT INTO nested_snap (id) VALUES (1)")
2295            .expect("concurrent insert");
2296
2297        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("nested_snap"));
2298        let nested = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
2299            .expect("nested frame builds");
2300
2301        assert_eq!(
2302            ReadFrame::snapshot(&nested).xid,
2303            ReadFrame::snapshot(&outer).xid,
2304            "nested execution must inherit the outer statement's snapshot"
2305        );
2306    }
2307
2308    /// The other side of the same rule, and the one that matters more:
2309    /// a nested statement built from *text* is a statement in its own
2310    /// right, not part of the enclosing one, so it must mint a fresh
2311    /// snapshot and observe everything committed before it.
2312    ///
2313    /// `apply_batched` (`impl_migrations.rs`) is the load-bearing caller:
2314    /// it drives `APPLY MIGRATION … BATCH N ROWS` by re-running the body
2315    /// with `LIMIT N` through `execute_query` until fewer than `N` rows
2316    /// are affected. Inheriting the outer `APPLY MIGRATION` statement's
2317    /// snapshot froze that loop's view, so every iteration re-selected
2318    /// the same rows and the migration never converged — 25 rows at
2319    /// `BATCH 7` hung instead of finishing in four iterations.
2320    #[test]
2321    fn nested_text_statement_sees_commits_made_since_the_outer_frame() {
2322        reset_thread_locals();
2323        let rt = fresh_runtime();
2324        rt.execute_query("CREATE TABLE nested_text_snap (id INT)")
2325            .expect("create table");
2326
2327        let outer = StatementExecutionFrame::build(
2328            &rt,
2329            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
2330        )
2331        .expect("outer frame builds");
2332        let _outer_guards = outer.install(&rt);
2333
2334        rt.execute_query("INSERT INTO nested_text_snap (id) VALUES (1)")
2335            .expect("concurrent insert");
2336
2337        let nested = StatementExecutionFrame::build(
2338            &rt,
2339            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
2340        )
2341        .expect("nested frame builds");
2342
2343        assert_ne!(
2344            ReadFrame::snapshot(&nested).xid,
2345            ReadFrame::snapshot(&outer).xid,
2346            "a nested text statement must mint a fresh snapshot, not inherit \
2347             the enclosing statement's frozen view"
2348        );
2349    }
2350
2351    /// A frame with no statement text must not silently answer an
2352    /// `AS OF` read with live data. `TableQuery::as_of` is consumed
2353    /// nowhere in the runtime or scan path, so the only correct
2354    /// behaviours are "resolve it" (text path) or "refuse".
2355    #[test]
2356    fn expr_frame_rejects_as_of_instead_of_reading_live() {
2357        reset_thread_locals();
2358        let rt = fresh_runtime();
2359
2360        let mut table = reddb_rql::ast::TableQuery::new("red_commits");
2361        table.as_of = Some(reddb_rql::ast::AsOfClause::Snapshot(1));
2362        let expr = QueryExpr::Table(table);
2363
2364        let err = match StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr)) {
2365            Err(err) => err,
2366            Ok(_) => panic!("AS OF without statement text must be refused, not read live"),
2367        };
2368        let msg = format!("{err}");
2369        assert!(
2370            msg.contains("AS OF cannot be resolved from a pre-parsed expression"),
2371            "expected a loud AS OF refusal, got: {msg}"
2372        );
2373    }
2374
2375    /// The expression path has no stable text key, so it must opt out of
2376    /// the result cache entirely. A kind label (`"table"`, `"insert"`)
2377    /// would key every statement of the same kind to one cache slot.
2378    #[test]
2379    fn expr_frame_opts_out_of_the_result_cache() {
2380        reset_thread_locals();
2381        let rt = fresh_runtime();
2382
2383        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("t"));
2384        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
2385            .expect("expr frame builds");
2386
2387        assert!(frame.cache_key().is_empty(), "expr frame has no cache key");
2388        assert!(
2389            !frame.can_read_result_cache(),
2390            "expr frame must never serve from the result cache"
2391        );
2392    }
2393}