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