Skip to main content

reddb_server/runtime/
impl_core.rs

1use super::*;
2use crate::auth::column_policy_gate::ColumnAccessRequest;
3use crate::auth::UserId;
4use crate::replication::cdc::ChangeRecord;
5use crate::storage::query::ast::TableSource;
6// Authorization surface moved to `super::authz` (issue #1622). Re-export the
7// free IAM/policy-column helpers so this module's remaining dispatch code
8// (and the `impl_core::decision_to_strings` path used by
9// `server::handlers_iam_policy`) keeps calling them unqualified.
10pub(crate) use super::authz::policy_columns::*;
11
12// Graph inline-TVF/analytics moved to `super::graph_tvf` (issue #1625) and the
13// RLS injection family to `super::rls_injection`. Re-export the free fns still
14// referenced from this module (and via `crate::runtime::impl_core::…` paths by
15// sibling files) so those call sites need no edits.
16pub(crate) use super::graph_tvf::{abstract_degree_centrality, is_graph_tvf_name};
17pub(crate) use super::rls_injection::{
18    apply_foreign_table_filters, inject_rls_filters, inject_rls_into_join, rls_is_enabled,
19    rls_policy_filter, rls_policy_filter_for_kind,
20};
21// VCS command parse/execute moved to `super::vcs_command` (issue #1626). The
22// execute methods are inherent `pub(crate)` methods resolved via `self`; the
23// free parse helpers still called from the central dispatch (and by
24// `collections_referenced` here) are re-exported so those call sites need no
25// edits.
26pub(crate) use super::vcs_command::{
27    parse_runtime_vcs_command, strip_explain_prefix, walk_collections,
28};
29
30// Ranking / metrics / SLO / analytics-source DDL moved to
31// `super::impl_ranking` (issue #1627). Execute methods are inherent
32// `pub(crate)` methods resolved via `self`; no call-site edits needed.
33
34// Config / secret / KV resolution moved to `super::impl_config_secret` and the
35// query-audit + control-event families to `super::impl_audit_control`
36// (issue #1628). Methods are inherent `pub(crate)` methods resolved via `self`;
37// the one free fn still called via a `crate::runtime::impl_core::…` path
38// (`control_event_outcome_for_error`, from `impl_backup`) is re-exported so that
39// call site needs no edit. The dispatch here also still calls the three
40// audit-planning free fns, so they are pulled back into scope unqualified.
41pub(crate) use super::impl_audit_control::{
42    control_event_outcome_for_error, query_audit_plan, query_control_event_specs,
43};
44// Config/secret free helpers still called unqualified from the dispatch here.
45pub(crate) use super::impl_config_secret::{
46    insert_config_json_path, secret_sql_value_to_string, seed_storage_deploy_config,
47    show_config_json_result, show_secrets_allows_key,
48};
49pub(super) use super::impl_core_outer_scope::{
50    first_column_values, query_references_outer_scope, relation_scopes_for_query,
51};
52pub(crate) use super::impl_core_result_cache_scopes::collect_table_refs;
53pub(super) use super::impl_core_result_cache_scopes::query_expr_result_cache_scopes;
54
55// Bootstrap / constructor / handle accessors moved to `super::impl_lifecycle`,
56// telemetry / gates / limits / shutdown to `super::impl_telemetry_accessors`,
57// and catalog / stats / maintenance to `super::impl_catalog_accessors`
58// (issue #1629). Methods are inherent `pub(crate)`/`pub` methods resolved via
59// `self`; the two moved free fns still called unqualified from the dispatch
60// here (`view_records_to_entities`) and the runtime-index rehydration paths
61// (`table_row_index_fields`) are re-exported so those call sites need no edit.
62pub(crate) use super::impl_lifecycle::{table_row_index_fields, view_records_to_entities};
63
64pub use super::execution_context::{
65    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
66    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
67    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
68    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
69    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
70    SnapshotBundle, SnapshotContext,
71};
72pub(crate) use super::execution_context::{
73    config_read_permitted, current_auth_identity, current_config_value, current_kv_value,
74    current_role_projected, current_scope_override, current_secret_value,
75    current_snapshot_requires_index_fallback, current_user_projected, has_scope_override_active,
76    kv_read_permitted, parse_set_local_tenant, update_current_config_value,
77    update_current_kv_value, update_current_secret_value, xids_visible_under_current_snapshot,
78    ConfigSnapshotGuard, CurrentSnapshotGuard, KvStoreGuard, ScopeOverrideGuard, SecretStoreGuard,
79    TxLocalTenantGuard,
80};
81
82/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
83/// CTE-aware parse in `execute_query` without paying for a full
84/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
85/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
86pub(super) fn has_with_prefix(sql: &str) -> bool {
87    let trimmed = sql.trim_start();
88    let head_end = trimmed
89        .find(|c: char| c.is_whitespace() || c == '(')
90        .unwrap_or(trimmed.len());
91    trimmed[..head_end].eq_ignore_ascii_case("WITH")
92}
93
94/// Same as `peek_top_level_as_of` but also returns the table name
95/// targeted by the AS OF clause (when the FROM clause names a
96/// concrete table). `None` for the table slot means scalar SELECT
97/// or a subquery source — callers treat those as "no enforcement".
98pub(super) fn peek_top_level_as_of_with_table(
99    sql: &str,
100) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
101    if !sql
102        .as_bytes()
103        .windows(5)
104        .any(|w| w.eq_ignore_ascii_case(b"as of"))
105    {
106        return None;
107    }
108    let parsed = crate::storage::query::parser::parse(sql).ok()?;
109    let crate::storage::query::ast::QueryExpr::Table(table) = parsed.query else {
110        return None;
111    };
112    let clause = table.as_of?;
113    let table_name = if table.table.is_empty() || table.table == "any" {
114        None
115    } else {
116        Some(table.table.clone())
117    };
118    let spec = match clause {
119        crate::storage::query::ast::AsOfClause::Commit(h) => {
120            crate::application::vcs::AsOfSpec::Commit(h)
121        }
122        crate::storage::query::ast::AsOfClause::Branch(b) => {
123            crate::application::vcs::AsOfSpec::Branch(b)
124        }
125        crate::storage::query::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
126        crate::storage::query::ast::AsOfClause::TimestampMs(ts) => {
127            crate::application::vcs::AsOfSpec::TimestampMs(ts)
128        }
129        crate::storage::query::ast::AsOfClause::Snapshot(x) => {
130            crate::application::vcs::AsOfSpec::Snapshot(x)
131        }
132    };
133    Some((spec, table_name))
134}
135
136pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
137    // Lowercase the bytes up to the first null/newline into a small
138    // stack buffer for cheap contains() checks. Most SQL fits in the
139    // buffer; longer queries fall back to owned lowercase.
140    const VOLATILE_TOKENS: &[&str] = &[
141        "pg_advisory_lock",
142        "pg_try_advisory_lock",
143        "pg_advisory_unlock",
144        "random()",
145        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
146        // vault state at execution time (#1370). A cached result would serve a
147        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
148        // query referencing them as volatile (never result-cache it).
149        "$config",
150        "$secret",
151        "$kv",
152        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
153        // omitted for now — they ARE volatile but today's tests rely
154        // on caching them. Revisit once a tighter volatility story
155        // lands.
156    ];
157    let lowered = sql.to_ascii_lowercase();
158    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
159}
160
161pub(super) fn query_is_ask_statement(sql: &str) -> bool {
162    let trimmed = sql.trim_start();
163    let head_end = trimmed
164        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
165        .unwrap_or(trimmed.len());
166    trimmed[..head_end].eq_ignore_ascii_case("ASK")
167}
168
169/// Pick the `(global_mode, collection_mode)` pair for an expression,
170/// or `None` for variants that opt out of intent-locking entirely
171/// (admin statements like `SHOW CONFIG`, transaction control, tenant
172/// toggles).
173///
174/// Phase-1 contract:
175/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
176/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
177/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
178pub(super) fn intent_lock_modes_for(
179    expr: &QueryExpr,
180) -> Option<(
181    crate::runtime::lock_manager::LockMode,
182    crate::runtime::lock_manager::LockMode,
183)> {
184    use crate::runtime::lock_manager::LockMode::{Exclusive, IntentExclusive, IntentShared};
185
186    match expr {
187        // Reads — IS / IS.
188        QueryExpr::Table(_)
189        | QueryExpr::Join(_)
190        | QueryExpr::Vector(_)
191        | QueryExpr::Hybrid(_)
192        | QueryExpr::Graph(_)
193        | QueryExpr::Path(_)
194        | QueryExpr::Ask(_)
195        | QueryExpr::SearchCommand(_)
196        | QueryExpr::GraphCommand(_)
197        | QueryExpr::RankOf(_)
198        | QueryExpr::ApproxRankOf(_)
199        | QueryExpr::RankRange(_)
200        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
201
202        // Writes — IX / IX. Non-tabular mutations (vector insert,
203        // graph node insert, queue push, timeseries point insert)
204        // don't carry their own dispatch arm here; they ride through
205        // the Insert variant or a command variant covered by the
206        // read-side arm above. P1.T4 expands only the TableQuery-ish
207        // writes; non-tabular kinds inherit when their DML variants
208        // land in later phases.
209        QueryExpr::Insert(_)
210        | QueryExpr::Update(_)
211        | QueryExpr::Delete(_)
212        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
213            Some((IntentExclusive, IntentExclusive))
214        }
215        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
216
217        // DDL — IX / X. A DDL against collection `c` blocks all
218        // other writers + readers on `c` but leaves other collections
219        // running (because Global stays IX, not X).
220        QueryExpr::CreateTable(_)
221        | QueryExpr::CreateCollection(_)
222        | QueryExpr::CreateVector(_)
223        | QueryExpr::DropTable(_)
224        | QueryExpr::DropGraph(_)
225        | QueryExpr::DropVector(_)
226        | QueryExpr::DropDocument(_)
227        | QueryExpr::DropKv(_)
228        | QueryExpr::DropCollection(_)
229        | QueryExpr::Truncate(_)
230        | QueryExpr::AlterTable(_)
231        | QueryExpr::CreateIndex(_)
232        | QueryExpr::DropIndex(_)
233        | QueryExpr::CreateTimeSeries(_)
234        | QueryExpr::CreateMetric(_)
235        | QueryExpr::AlterMetric(_)
236        | QueryExpr::CreateSlo(_)
237        | QueryExpr::DropTimeSeries(_)
238        | QueryExpr::CreateQueue(_)
239        | QueryExpr::AlterQueue(_)
240        | QueryExpr::DropQueue(_)
241        | QueryExpr::CreateTree(_)
242        | QueryExpr::DropTree(_)
243        | QueryExpr::CreatePolicy(_)
244        | QueryExpr::DropPolicy(_)
245        | QueryExpr::CreateView(_)
246        | QueryExpr::DropView(_)
247        | QueryExpr::RefreshMaterializedView(_)
248        | QueryExpr::CreateSchema(_)
249        | QueryExpr::DropSchema(_)
250        | QueryExpr::CreateSequence(_)
251        | QueryExpr::DropSequence(_)
252        | QueryExpr::CreateServer(_)
253        | QueryExpr::DropServer(_)
254        | QueryExpr::CreateForeignTable(_)
255        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
256
257        // Admin / control — skip intent locks. `SET TENANT`,
258        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
259        // `VACUUM`, etc. don't touch collection data the same way
260        // and the existing transaction layer already serialises the
261        // pieces that matter.
262        _ => None,
263    }
264}
265
266/// Best-effort collection inventory for an expression. Used to pick
267/// `Collection(...)` resources for the intent-lock guard. Overshoots
268/// are fine (take an extra IS, benign); undershoots leak writes past
269/// DDL X locks, so err on the side of listing more names.
270pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
271    let mut out = Vec::new();
272    walk_collections(expr, &mut out);
273    out.sort();
274    out.dedup();
275    out
276}
277
278impl RedDBRuntime {
279    /// Execute a query under a typed scope override without embedding
280    /// the tenant / user / role values into the SQL string. Use this
281    /// from transport middleware (HTTP / gRPC / worker loops) where the
282    /// scope is resolved from auth claims and the SQL is a parameterised
283    /// template — avoids the string-concat injection risk of building
284    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
285    /// prepared statements that didn't know about tenancy.
286    ///
287    /// Precedence matches the `WITHIN` clause: the passed `scope`
288    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
289    /// The override is pushed on the thread-local scope stack for the
290    /// duration of the call and popped on return — pool-shared
291    /// connections cannot leak it across requests.
292    pub fn execute_query_with_scope(
293        &self,
294        query: &str,
295        scope: crate::runtime::within_clause::ScopeOverride,
296    ) -> RedDBResult<RuntimeQueryResult> {
297        if scope.is_empty() {
298            return self.execute_query(query);
299        }
300        let _scope_guard = ScopeOverrideGuard::install(scope);
301        self.execute_query(query)
302    }
303
304    /// Issue #205 — single lifecycle exit for slow-query logging.
305    ///
306    /// `execute_query_inner` does the real work; this wrapper times it
307    /// and, if elapsed exceeds the configured threshold, hands the
308    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
309    /// SlowQueryLogger. The threshold + sample_pct were captured at
310    /// SlowQueryLogger construction (runtime startup), so the per-call
311    /// cost on below-threshold paths is one relaxed atomic load.
312    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
313        let started = std::time::Instant::now();
314        self.inner.node_load_telemetry.query_start();
315        let result = self.execute_query_inner(query);
316        self.finish_query_lifecycle(query, started, result)
317    }
318
319    /// Execute a SQL statement with already-decoded positional bind
320    /// parameters. Transports should call this instead of parsing +
321    /// binding on their side and then reaching for `execute_query_expr`:
322    /// this entry keeps parameterized statements inside the same
323    /// statement lifecycle as textual SQL (snapshot guard, config/secret
324    /// guards, coarse auth, intent locks, slow-query logging, integrity
325    /// tombstone filtering, and causal bookmarks).
326    pub fn execute_query_with_params(
327        &self,
328        query: &str,
329        params: &[Value],
330    ) -> RedDBResult<RuntimeQueryResult> {
331        if params.is_empty() {
332            return self.execute_query(query);
333        }
334        let started = std::time::Instant::now();
335        self.inner.node_load_telemetry.query_start();
336        let result = self.execute_query_with_params_inner(query, params);
337        self.finish_query_lifecycle(query, started, result)
338    }
339
340    fn finish_query_lifecycle(
341        &self,
342        query: &str,
343        started: std::time::Instant,
344        mut result: RedDBResult<RuntimeQueryResult>,
345    ) -> RedDBResult<RuntimeQueryResult> {
346        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
347        // results before they reach any consumer. Fast no-op (one relaxed
348        // atomic load) unless an input-stream digest mismatch has tombstoned
349        // a RID range on this store.
350        if let Ok(ref mut query_result) = result {
351            if query_result.statement_type == "select" {
352                self.filter_integrity_tombstoned(&mut query_result.result);
353            }
354        }
355        let elapsed_ms = started.elapsed().as_millis() as u64;
356
357        // Build EffectiveScope from the same thread-locals frame-build
358        // consults — keeps the slow-log row consistent with the audit /
359        // RLS view of "this statement". `ai_scope()` is the canonical
360        // builder.
361        let scope = self.ai_scope();
362        let kind = match result
363            .as_ref()
364            .map(|r| r.statement_type)
365            .unwrap_or("select")
366        {
367            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
368            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
369            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
370            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
371            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
372        };
373        // SQL redaction: pass the raw query through. The slow-query
374        // logger writes structured JSON so embedded literals stay
375        // escape-safe at the JSON boundary (proven by
376        // `adversarial_sql_is_escape_safe` in slow_query_logger.rs).
377        // PII redaction (e.g. literal masking) is a follow-up.
378        self.inner
379            .slow_query_logger
380            .record(kind, elapsed_ms, query.to_string(), &scope);
381
382        // Issue #1241 — record latency into the bounded per-`kind`
383        // histogram substrate (always, not only above the slow-query
384        // threshold). `started.elapsed()` is re-read here for sub-ms
385        // resolution; the cost is one `Instant::now` plus a handful of
386        // relaxed atomic adds (see `query_latency_telemetry` docs).
387        self.inner
388            .query_latency_telemetry
389            .observe(kind, started.elapsed().as_secs_f64());
390
391        // Issue #1245 — decrement the active-query gauge. One relaxed
392        // atomic sub; the matching increment happened at execute_query /
393        // execute_query_with_params entry.
394        self.inner.node_load_telemetry.query_finish();
395
396        if let Ok(ref mut query_result) = result {
397            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
398                let bookmark = crate::replication::CausalBookmark::new(
399                    self.current_replication_term(),
400                    self.cdc_current_lsn(),
401                );
402                query_result.bookmark = Some(bookmark.encode());
403            }
404        }
405
406        result
407    }
408
409    fn execute_query_with_params_inner(
410        &self,
411        query: &str,
412        params: &[Value],
413    ) -> RedDBResult<RuntimeQueryResult> {
414        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
415        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
416            RedDBError::Validation {
417                message: err.to_string(),
418                validation: crate::json!({
419                    "code": "INVALID_PARAMS",
420                    "surface": "query.params",
421                }),
422            }
423        })?;
424        self.execute_bound_query_expr_in_frame(query, bound)
425    }
426
427    fn execute_bound_query_expr_in_frame(
428        &self,
429        query: &str,
430        expr: QueryExpr,
431    ) -> RedDBResult<RuntimeQueryResult> {
432        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
433        let execution_query = rewritten_query.as_deref().unwrap_or(query);
434        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
435        // Box the guards so the ~640-byte StatementFrameGuards struct lives on
436        // the heap rather than the call stack — important for recursive paths
437        // (view refresh, nested queries) where the stack can be as small as 2 MB.
438        let _frame_guards = Box::new(frame.install(self));
439        let _log_span = crate::telemetry::span::query_span(query).entered();
440
441        let expr = self.rewrite_view_refs(expr);
442        let mode = detect_mode(execution_query);
443        let control_event_specs = query_control_event_specs(&expr);
444        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
445            Ok(guard) => guard,
446            Err(err) => {
447                let outcome = control_event_outcome_for_error(&err);
448                for spec in &control_event_specs {
449                    self.emit_control_event(
450                        spec.kind,
451                        outcome,
452                        spec.action,
453                        spec.resource.clone(),
454                        Some(err.to_string()),
455                        spec.fields.clone(),
456                    )?;
457                }
458                return Err(err);
459            }
460        };
461
462        let mut result = self.dispatch_expr(expr, query, mode)?;
463        if result.statement_type == "select" {
464            self.apply_secret_decryption(&mut result);
465        }
466        Ok(result)
467    }
468
469    pub fn causal_session(&self) -> crate::runtime::CausalSession {
470        crate::runtime::CausalSession {
471            runtime: self.clone(),
472            bookmark: None,
473            wait_timeout: std::time::Duration::from_secs(5),
474        }
475    }
476
477    pub fn wait_for_bookmark(
478        &self,
479        bookmark: &crate::replication::CausalBookmark,
480        timeout: std::time::Duration,
481    ) -> RedDBResult<()> {
482        let deadline = std::time::Instant::now() + timeout;
483        loop {
484            let applied_lsn = self.local_contiguous_applied_lsn();
485            if applied_lsn >= bookmark.commit_lsn() {
486                return Ok(());
487            }
488            let now = std::time::Instant::now();
489            if now >= deadline {
490                return Err(RedDBError::InvalidOperation(format!(
491                    "timed out waiting for causal bookmark lsn {}; applied={}",
492                    bookmark.commit_lsn(),
493                    applied_lsn
494                )));
495            }
496            let remaining = deadline.saturating_duration_since(now);
497            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
498        }
499    }
500
501    fn local_contiguous_applied_lsn(&self) -> u64 {
502        match self.inner.db.options().replication.role {
503            crate::replication::ReplicationRole::Replica { .. } => {
504                self.config_u64("red.replication.last_applied_lsn", 0)
505            }
506            _ => self.cdc_current_lsn(),
507        }
508    }
509
510    #[inline(never)]
511    pub(super) fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
512        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
513        //
514        // Moved above every boot-cost the normal path pays (WITHIN
515        // strip, SET LOCAL parse, tx_local_tenants read, snapshot
516        // guard, tracing span, tx_contexts read) because the bench's
517        // `select_point` scenario was observed at 28× vs PostgreSQL —
518        // the dominant cost wasn't the entity fetch but the ceremony
519        // before it. Only fires when there's no ambient transaction
520        // context or WITHIN override, so the snapshot install we skip
521        // truly is a no-op for this query.
522        if !has_scope_override_active()
523            && !query.trim_start().starts_with("WITHIN")
524            && !query.trim_start().starts_with("within")
525            && !self.inner.query_audit.has_rules()
526            && !self
527                .inner
528                .tx_contexts
529                .read()
530                .contains_key(&current_connection_id())
531        {
532            if let Some(result) = self.try_fast_entity_lookup(query) {
533                return result;
534            }
535        }
536
537        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
538        // strip the prefix, push a stack-scoped override, recurse on
539        // the inner statement, pop on return. Stack lives in a
540        // thread-local but is balanced by the RAII guard, so a
541        // pool-shared connection cannot leak the override across
542        // requests and an early `?` return still pops cleanly.
543        match crate::runtime::within_clause::try_strip_within_prefix(query) {
544            Ok(Some((scope, inner))) => {
545                let _scope_guard = ScopeOverrideGuard::install(scope);
546                // Re-enter the inner path, NOT `execute_query`, so the
547                // slow-query lifecycle hook records exactly one row per
548                // top-level statement (the WITHIN-stripped form would
549                // double-record).
550                return self.execute_query_inner(inner);
551            }
552            Ok(None) => {}
553            Err(msg) => return Err(RedDBError::Query(msg)),
554        }
555
556        // `EXPLAIN ANALYZE <dml>` — paid truth tier. Executes the
557        // mutating statement inside a transaction that is always
558        // rolled back, then reports the actual affected row count.
559        if let Some(inner) = strip_explain_analyze_prefix(query) {
560            return self.explain_analyze_as_rows(query, inner);
561        }
562
563        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
564        // inner statement (WITHOUT executing it) and returns the
565        // CanonicalLogicalNode tree as rows so the caller can see the
566        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
567        // is a distinct schema-diff command and continues down the
568        // regular SQL path.
569        if let Some(inner) = strip_explain_prefix(query) {
570            return self.explain_as_rows(query, inner);
571        }
572
573        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
574        // override and return. Outside a transaction the statement is
575        // an error (matches PG semantics: SET LOCAL only takes effect
576        // within an active transaction).
577        if let Some(value) = parse_set_local_tenant(query)? {
578            let conn_id = current_connection_id();
579            if !self.inner.tx_contexts.read().contains_key(&conn_id) {
580                return Err(RedDBError::Query(
581                    "SET LOCAL TENANT requires an active transaction".to_string(),
582                ));
583            }
584            self.inner
585                .tx_local_tenants
586                .write()
587                .insert(conn_id, value.clone());
588            return Ok(RuntimeQueryResult::ok_message(
589                query.to_string(),
590                &match &value {
591                    Some(id) => format!("local tenant set: {id}"),
592                    None => "local tenant cleared".to_string(),
593                },
594                "set_local_tenant",
595            ));
596        }
597
598        if super::red_schema::is_system_schema_write(query) {
599            return Err(RedDBError::Query(
600                super::red_schema::READ_ONLY_ERROR.to_string(),
601            ));
602        }
603
604        if let Some(command) = parse_runtime_vcs_command(query) {
605            return self.execute_vcs_command(query, detect_mode(query), command?);
606        }
607
608        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
609        {
610            return self.execute_create_analytics_source(query, create_source);
611        }
612
613        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
614        // v0. The descriptor itself is readable through
615        // `red.analytics.metrics`; the *output* read returns a
616        // structured error so callers can tell "execution engine not yet
617        // built" apart from "metric does not exist".
618        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
619            return Err(super::metric_descriptor_catalog::read_output_unsupported(
620                &path,
621            ));
622        }
623
624        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
625        // declarations are still recognised before the general parser.
626        // Rank reads themselves are parser AST nodes, including Redis-flavor
627        // Z* sugar that desugars to the same canonical rank shapes.
628        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
629            return self.execute_create_ranking(query, parsed?);
630        }
631        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
632            return self.execute_show_rankings(query);
633        }
634
635        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
636        let execution_query = rewritten_query.as_deref().unwrap_or(query);
637
638        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
639        let _frame_guards = Box::new(frame.install(self));
640
641        // Phase 6 logging: enter a span stamped with conn_id / tenant
642        // / query_len. Every downstream tracing::info!/warn!/error!
643        // inherits these fields — no need to thread them manually
644        // through storage/scan layers. Entered AFTER the WITHIN /
645        // SET LOCAL TENANT resolution above so the span reflects the
646        // effective scope for this statement.
647        let _log_span = crate::telemetry::span::query_span(query).entered();
648
649        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
650        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
651            return self.execute_query_expr(rewritten);
652        }
653
654        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
655        if !self.inner.query_audit.has_rules() {
656            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
657                return result;
658            }
659        }
660
661        // ── Result cache: return cached result if still fresh (30s TTL) ──
662        if !self.inner.query_audit.has_rules() {
663            if let Some(result) = frame.read_result_cache(self) {
664                return Ok(result);
665            }
666        }
667
668        let prepared = frame.prepare_statement(self, execution_query)?;
669        let mode = prepared.mode;
670        let expr = prepared.expr;
671
672        let statement = query_expr_name(&expr);
673        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
674        let control_event_specs = query_control_event_specs(&expr);
675        let query_audit_plan = query_audit_plan(&expr);
676
677        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
678            Ok(guard) => guard,
679            Err(err) => {
680                let outcome = control_event_outcome_for_error(&err);
681                for spec in &control_event_specs {
682                    self.emit_control_event(
683                        spec.kind,
684                        outcome,
685                        spec.action,
686                        spec.resource.clone(),
687                        Some(err.to_string()),
688                        spec.fields.clone(),
689                    )?;
690                }
691                return Err(err);
692            }
693        };
694        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
695        let query_audit_started = std::time::Instant::now();
696
697        let query_result = match expr {
698            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
699                // Apply MVCC visibility + RLS gate while materialising the
700                // graph: every node entity is screened against the source
701                // collection's policy chain (basic and `Nodes`-targeted)
702                // and dropped when the caller's tenant / role doesn't
703                // admit it. Edges are pruned automatically because the
704                // graph builder skips edges whose endpoints aren't in
705                // `allowed_nodes`.
706                let (graph, node_properties, edge_properties) =
707                    self.materialize_graph_with_rls()?;
708                let result =
709                    crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties(
710                        &graph,
711                        &expr,
712                        node_properties,
713                        edge_properties,
714                    )
715                        .map_err(|err| RedDBError::Query(err.to_string()))?;
716
717                Ok(RuntimeQueryResult {
718                    query: query.to_string(),
719                    mode,
720                    statement,
721                    engine: "materialized-graph",
722                    result,
723                    affected_rows: 0,
724                    statement_type: "select",
725                    bookmark: None,
726                    notice: None,
727                })
728            }
729            QueryExpr::Table(table) => {
730                let table = self.resolve_table_expr_subqueries(
731                    table,
732                    &frame as &dyn super::statement_frame::ReadFrame,
733                )?;
734                // Table-valued functions (e.g. components(g)) dispatch to a
735                // read-only executor before any catalog/virtual-table routing
736                // (issue #795).
737                if let Some(TableSource::Function {
738                    name,
739                    args,
740                    named_args,
741                }) = table.source.clone()
742                {
743                    // The graph-collection form is cacheable (issue #802): the
744                    // result-cache read at the top of this function keys on the
745                    // query string, and `result_cache_scopes` carries the graph
746                    // collection (see `collect_table_source_scopes`) so a write
747                    // to it invalidates the entry. Deterministic algorithm
748                    // output is worth caching at any row count, so the write
749                    // bypasses the generic ≤5-row payload heuristic.
750                    let tvf_result = RuntimeQueryResult {
751                        query: query.to_string(),
752                        mode,
753                        statement,
754                        engine: "runtime-graph-tvf",
755                        result: self.execute_table_function(&name, &args, &named_args)?,
756                        affected_rows: 0,
757                        statement_type: "select",
758                        bookmark: None,
759                        notice: None,
760                    };
761                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
762                    return Ok(tvf_result);
763                }
764                // Inline-graph TVF (issue #799): the graph is supplied by two
765                // subqueries instead of a collection reference. Unlike the
766                // graph-collection form, the result IS cacheable — its cache
767                // key is the query string (the result-cache read at the top of
768                // `execute_query_inner` keys on it) and `result_cache_scopes`
769                // already carries the `nodes`/`edges` source collections, so a
770                // write to any of them invalidates the entry.
771                if let Some(TableSource::InlineGraphFunction {
772                    name,
773                    nodes,
774                    edges,
775                    named_args,
776                }) = table.source.clone()
777                {
778                    let inline_result = RuntimeQueryResult {
779                        query: query.to_string(),
780                        mode,
781                        statement,
782                        engine: "runtime-graph-tvf-inline",
783                        result: self.execute_inline_graph_function(
784                            &name,
785                            &nodes,
786                            &edges,
787                            &named_args,
788                        )?,
789                        affected_rows: 0,
790                        statement_type: "select",
791                        bookmark: None,
792                        notice: None,
793                    };
794                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
795                    return Ok(inline_result);
796                }
797                if super::red_schema::is_virtual_table(&table.table) {
798                    return Ok(RuntimeQueryResult {
799                        query: query.to_string(),
800                        mode,
801                        statement,
802                        engine: "runtime-red-schema",
803                        result: super::red_schema::red_query(
804                            self,
805                            &table.table,
806                            &table,
807                            &frame as &dyn super::statement_frame::ReadFrame,
808                        )?,
809                        affected_rows: 0,
810                        statement_type: "select",
811                        bookmark: None,
812                        notice: None,
813                    });
814                }
815
816                // `<graph>.<output>` analytics virtual view (issue #800).
817                // Recomputed on demand — intentionally not result-cached, so it
818                // always reflects the current graph data.
819                if let Some(view_result) = self.try_resolve_analytics_view(
820                    &table,
821                    &frame as &dyn super::statement_frame::ReadFrame,
822                )? {
823                    return Ok(RuntimeQueryResult {
824                        query: query.to_string(),
825                        mode,
826                        statement,
827                        engine: "runtime-graph-analytics-view",
828                        result: view_result,
829                        affected_rows: 0,
830                        statement_type: "select",
831                        bookmark: None,
832                        notice: None,
833                    });
834                }
835
836                if let Some(result) = self.execute_probabilistic_select(&table)? {
837                    return Ok(RuntimeQueryResult {
838                        query: query.to_string(),
839                        mode,
840                        statement,
841                        engine: "runtime-probabilistic",
842                        result,
843                        affected_rows: 0,
844                        statement_type: "select",
845                        bookmark: None,
846                        notice: None,
847                    });
848                }
849
850                // Foreign-table intercept (Phase 3.2.2 PG parity).
851                //
852                // When the referenced table matches a `CREATE FOREIGN TABLE`
853                // registration, short-circuit into the FDW scan. Phase 3.2
854                // wrappers don't yet support pushdown, so filters/projections
855                // apply post-scan via `apply_foreign_table_filters` — good
856                // enough for correctness; perf work lands in 3.2.3.
857                if self.inner.foreign_tables.is_foreign_table(&table.table) {
858                    let records = self
859                        .inner
860                        .foreign_tables
861                        .scan(&table.table)
862                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
863                    let result = apply_foreign_table_filters(records, &table);
864                    return Ok(RuntimeQueryResult {
865                        query: query.to_string(),
866                        mode,
867                        statement,
868                        engine: "runtime-fdw",
869                        result,
870                        affected_rows: 0,
871                        statement_type: "select",
872                        bookmark: None,
873                        notice: None,
874                    });
875                }
876
877                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
878                //
879                // When RLS is enabled on this table, fetch every policy
880                // that applies to the current (role, SELECT) pair and
881                // fold them into the query's WHERE clause: policies
882                // OR-combine (any of them admitting the row is enough),
883                // then AND into the caller's existing filter.
884                //
885                // Anonymous callers (no thread-local identity) pass
886                // `role = None`; policies with a specific `TO role`
887                // clause skip, but `TO PUBLIC` policies still apply.
888                //
889                // When `inject_rls_filters` returns `None` the table has
890                // RLS enabled but no policy admits the caller's role —
891                // short-circuit with an empty result set instead of
892                // synthesising a contradiction filter.
893                let Some(table_with_rls) = self.authorize_relational_table_select(
894                    table,
895                    &frame as &dyn super::statement_frame::ReadFrame,
896                )?
897                else {
898                    let empty = crate::storage::query::unified::UnifiedResult::empty();
899                    return Ok(RuntimeQueryResult {
900                        query: query.to_string(),
901                        mode,
902                        statement,
903                        engine: "runtime-table-rls",
904                        result: empty,
905                        affected_rows: 0,
906                        statement_type: "select",
907                        bookmark: None,
908                        notice: None,
909                    });
910                };
911                Ok(RuntimeQueryResult {
912                    query: query.to_string(),
913                    mode,
914                    statement,
915                    engine: "runtime-table",
916                    // #885: lend the frame-owned row-buffer arena to the
917                    // streaming path so chunk buffers are reused across
918                    // this statement's chunk-fetches instead of allocated
919                    // fresh per chunk. This is the table-query dispatch
920                    // that runs under a `StatementExecutionFrame`; the
921                    // frameless prepared/subquery paths keep `None`.
922                    result: execute_runtime_table_query_in(
923                        &self.inner.db,
924                        &table_with_rls,
925                        Some(&self.inner.index_store),
926                        Some(frame.row_arena()),
927                    )?,
928                    affected_rows: 0,
929                    statement_type: "select",
930                    bookmark: None,
931                    notice: None,
932                })
933            }
934            QueryExpr::Join(join) => {
935                // Fold per-table RLS filters into each `QueryExpr::Table`
936                // leaf of the join tree before executing. Without this
937                // the join executor scans both tables raw and ignores
938                // policies — a `WITHIN TENANT 'x'` against a join of
939                // two tenant-scoped tables would leak cross-tenant rows.
940                // When any leaf has RLS enabled and zero matching policy,
941                // short-circuit to an empty join result instead of
942                // emitting a contradiction filter.
943                let join_with_rls = match self.authorize_relational_join_select(
944                    join,
945                    &frame as &dyn super::statement_frame::ReadFrame,
946                )? {
947                    Some(j) => j,
948                    None => {
949                        return Ok(RuntimeQueryResult {
950                            query: query.to_string(),
951                            mode,
952                            statement,
953                            engine: "runtime-join-rls",
954                            result: crate::storage::query::unified::UnifiedResult::empty(),
955                            affected_rows: 0,
956                            statement_type: "select",
957                            bookmark: None,
958                            notice: None,
959                        });
960                    }
961                };
962                Ok(RuntimeQueryResult {
963                    query: query.to_string(),
964                    mode,
965                    statement,
966                    engine: "runtime-join",
967                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
968                    affected_rows: 0,
969                    statement_type: "select",
970                    bookmark: None,
971                    notice: None,
972                })
973            }
974            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
975                query: query.to_string(),
976                mode,
977                statement,
978                engine: "runtime-vector",
979                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
980                affected_rows: 0,
981                statement_type: "select",
982                bookmark: None,
983                notice: None,
984            }),
985            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
986                query: query.to_string(),
987                mode,
988                statement,
989                engine: "runtime-hybrid",
990                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
991                affected_rows: 0,
992                statement_type: "select",
993                bookmark: None,
994                notice: None,
995            }),
996            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
997            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
998            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
999            // DML execution
1000            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
1001                Err(RedDBError::Query(
1002                    super::red_schema::READ_ONLY_ERROR.to_string(),
1003                ))
1004            }
1005            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
1006                Err(RedDBError::Query(
1007                    super::red_schema::READ_ONLY_ERROR.to_string(),
1008                ))
1009            }
1010            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
1011                Err(RedDBError::Query(
1012                    super::red_schema::READ_ONLY_ERROR.to_string(),
1013                ))
1014            }
1015            QueryExpr::Insert(ref insert) => self
1016                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
1017                    self.execute_insert(query, insert)
1018                }),
1019            QueryExpr::Update(ref update) => self
1020                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
1021                    self.execute_update(query, update)
1022                }),
1023            QueryExpr::Delete(ref delete) => self
1024                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
1025                    self.execute_delete(query, delete)
1026                }),
1027            // DDL execution
1028            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
1029            QueryExpr::CreateCollection(ref create) => {
1030                self.execute_create_collection(query, create)
1031            }
1032            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
1033            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
1034            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
1035            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
1036            QueryExpr::DropDocument(ref drop_document) => {
1037                self.execute_drop_document(query, drop_document)
1038            }
1039            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
1040            QueryExpr::DropCollection(ref drop_collection) => {
1041                self.execute_drop_collection(query, drop_collection)
1042            }
1043            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
1044            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
1045            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
1046            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
1047            QueryExpr::ForkStore(ref fork) => self.execute_fork_store(query, fork),
1048            QueryExpr::PromoteFork(ref promote_fork) => {
1049                self.execute_promote_fork(query, promote_fork)
1050            }
1051            QueryExpr::DropFork(ref drop_fork) => self.execute_drop_fork(query, drop_fork),
1052            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
1053            // Graph analytics commands
1054            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
1055            // Search commands
1056            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
1057            // ASK: RAG query with LLM synthesis
1058            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
1059            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
1060            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
1061            QueryExpr::ProbabilisticCommand(ref cmd) => {
1062                self.execute_probabilistic_command(query, cmd)
1063            }
1064            // Time-series DDL
1065            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
1066            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
1067            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
1068            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
1069            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
1070            // Queue DDL and commands
1071            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
1072            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
1073            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
1074            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
1075            QueryExpr::QueueCommand(ref cmd) => self
1076                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
1077            QueryExpr::EventsBackfill(ref backfill) => {
1078                self.execute_events_backfill(query, backfill)
1079            }
1080            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
1081                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
1082            ))),
1083            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
1084            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
1085            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
1086            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
1087            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
1088            // SET CONFIG key = value
1089            QueryExpr::SetConfig { ref key, ref value } => {
1090                if key.starts_with("red.secret.") {
1091                    return Err(RedDBError::Query(
1092                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
1093                    ));
1094                }
1095                if key.starts_with("red.secrets.") {
1096                    return Err(RedDBError::Query(
1097                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
1098                    ));
1099                }
1100                // ADR-0068 §5 clean break: reject the removed AI config keys
1101                // (old `default.*` provider/model and per-alias base_url shape)
1102                // with a didactic error naming the replacement key.
1103                crate::ai::validate_ai_config_key_on_write(key)?;
1104                match self.check_managed_config_write_for_set_config(key) {
1105                    Err(err) => Err(err),
1106                    Ok(()) => {
1107                        let store = self.inner.db.store();
1108                        let json_val = match value {
1109                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
1110                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
1111                            Value::Float(n) => crate::serde_json::Value::Number(*n),
1112                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
1113                            _ => crate::serde_json::Value::String(value.to_string()),
1114                        };
1115                        store.set_config_tree(key, &json_val);
1116                        update_current_config_value(key, value.clone());
1117                        // Config changes can flip runtime behavior mid-session
1118                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
1119                        // result cache so subsequent reads re-execute against
1120                        // the new config.
1121                        self.invalidate_result_cache();
1122                        Ok(RuntimeQueryResult::ok_message(
1123                            query.to_string(),
1124                            &format!("config set: {key}"),
1125                            "set",
1126                        ))
1127                    }
1128                }
1129            }
1130            // SET SECRET key = value
1131            QueryExpr::SetSecret { ref key, ref value } => {
1132                if key.starts_with("red.config.") {
1133                    return Err(RedDBError::Query(
1134                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
1135                    ));
1136                }
1137                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1138                    RedDBError::Query("SET SECRET requires an enabled, unsealed vault".to_string())
1139                })?;
1140                self.check_secret_write_privilege(&auth_store, key)?;
1141                if matches!(value, Value::Null) {
1142                    auth_store
1143                        .vault_kv_try_delete(key)
1144                        .map_err(|err| RedDBError::Query(err.to_string()))?;
1145                    update_current_secret_value(key, None);
1146                    self.invalidate_result_cache();
1147                    return Ok(RuntimeQueryResult::ok_message(
1148                        query.to_string(),
1149                        &format!("secret deleted: {key}"),
1150                        "delete_secret",
1151                    ));
1152                }
1153                let value = secret_sql_value_to_string(value)?;
1154                auth_store
1155                    .vault_kv_try_set(key.clone(), value.clone())
1156                    .map_err(|err| RedDBError::Query(err.to_string()))?;
1157                update_current_secret_value(key, Some(value));
1158                self.invalidate_result_cache();
1159                Ok(RuntimeQueryResult::ok_message(
1160                    query.to_string(),
1161                    &format!("secret set: {key}"),
1162                    "set_secret",
1163                ))
1164            }
1165            // DELETE SECRET key
1166            QueryExpr::DeleteSecret { ref key } => {
1167                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1168                    RedDBError::Query(
1169                        "DELETE SECRET requires an enabled, unsealed vault".to_string(),
1170                    )
1171                })?;
1172                self.check_secret_write_privilege(&auth_store, key)?;
1173                let deleted = auth_store
1174                    .vault_kv_try_delete(key)
1175                    .map_err(|err| RedDBError::Query(err.to_string()))?;
1176                if deleted {
1177                    update_current_secret_value(key, None);
1178                }
1179                self.invalidate_result_cache();
1180                Ok(RuntimeQueryResult::ok_message(
1181                    query.to_string(),
1182                    &format!("secret deleted: {key}"),
1183                    if deleted {
1184                        "delete_secret"
1185                    } else {
1186                        "delete_secret_not_found"
1187                    },
1188                ))
1189            }
1190            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
1191            QueryExpr::SetKv { ref key, ref value } => {
1192                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1193                    RedDBError::Query("SET KV requires an auth store".to_string())
1194                })?;
1195                self.check_kv_write_privilege(&auth_store, key)?;
1196                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
1197                if matches!(value, Value::Null) {
1198                    auth_store.plain_kv_delete(key);
1199                    update_current_kv_value(key, None);
1200                    self.invalidate_result_cache();
1201                    return Ok(RuntimeQueryResult::ok_message(
1202                        query.to_string(),
1203                        &format!("kv deleted: {key}"),
1204                        "delete_kv",
1205                    ));
1206                }
1207                let value = secret_sql_value_to_string(value)?;
1208                auth_store.plain_kv_set(key.clone(), value.clone());
1209                update_current_kv_value(key, Some(value));
1210                self.invalidate_result_cache();
1211                Ok(RuntimeQueryResult::ok_message(
1212                    query.to_string(),
1213                    &format!("kv set: {key}"),
1214                    "set_kv",
1215                ))
1216            }
1217            // DELETE KV key
1218            QueryExpr::DeleteKv { ref key } => {
1219                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1220                    RedDBError::Query("DELETE KV requires an auth store".to_string())
1221                })?;
1222                self.check_kv_write_privilege(&auth_store, key)?;
1223                let deleted = auth_store.plain_kv_delete(key);
1224                if deleted {
1225                    update_current_kv_value(key, None);
1226                }
1227                self.invalidate_result_cache();
1228                Ok(RuntimeQueryResult::ok_message(
1229                    query.to_string(),
1230                    &format!("kv deleted: {key}"),
1231                    if deleted {
1232                        "delete_kv"
1233                    } else {
1234                        "delete_kv_not_found"
1235                    },
1236                ))
1237            }
1238            // SHOW SECRET[S] [prefix]
1239            QueryExpr::ShowSecrets { ref prefix } => {
1240                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1241                    RedDBError::Query("SHOW SECRET requires an enabled, unsealed vault".to_string())
1242                })?;
1243                if !auth_store.is_vault_backed() {
1244                    return Err(RedDBError::Query(
1245                        "SHOW SECRET requires an enabled, unsealed vault".to_string(),
1246                    ));
1247                }
1248                let mut keys = auth_store.vault_kv_keys();
1249                keys.sort();
1250                let mut result = UnifiedResult::with_columns(vec![
1251                    "key".into(),
1252                    "value".into(),
1253                    "status".into(),
1254                ]);
1255                for key in keys {
1256                    if !show_secrets_allows_key(&key) {
1257                        continue;
1258                    }
1259                    if let Some(ref pfx) = prefix {
1260                        if !key.starts_with(pfx) {
1261                            continue;
1262                        }
1263                    }
1264                    let mut record = UnifiedRecord::new();
1265                    record.set("key", Value::text(key));
1266                    record.set("value", Value::text("***"));
1267                    record.set("status", Value::text("active"));
1268                    result.push(record);
1269                }
1270                Ok(RuntimeQueryResult {
1271                    query: query.to_string(),
1272                    mode,
1273                    statement: "show_secrets",
1274                    engine: "runtime-secret",
1275                    result,
1276                    affected_rows: 0,
1277                    statement_type: "select",
1278                    bookmark: None,
1279                    notice: None,
1280                })
1281            }
1282            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
1283            QueryExpr::ShowConfig {
1284                ref prefix,
1285                as_json,
1286            } => {
1287                let store = self.inner.db.store();
1288                let all_collections = store.list_collections();
1289                if !all_collections.contains(&"red_config".to_string()) {
1290                    if as_json {
1291                        return Ok(show_config_json_result(
1292                            query,
1293                            mode,
1294                            prefix,
1295                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
1296                        ));
1297                    }
1298                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1299                    return Ok(RuntimeQueryResult {
1300                        query: query.to_string(),
1301                        mode,
1302                        statement: "show_config",
1303                        engine: "runtime-config",
1304                        result,
1305                        affected_rows: 0,
1306                        statement_type: "select",
1307                        bookmark: None,
1308                        notice: None,
1309                    });
1310                }
1311                let manager = store
1312                    .get_collection("red_config")
1313                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
1314                let entities = manager.query_all(|_| true);
1315                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
1316                for entity in entities {
1317                    if let EntityData::Row(ref row) = entity.data {
1318                        if let Some(ref named) = row.named {
1319                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
1320                            let val = named.get("value").cloned().unwrap_or(Value::Null);
1321                            let key_str = match &key_val {
1322                                Value::Text(s) => s.as_ref(),
1323                                _ => continue,
1324                            };
1325                            if let Some(ref pfx) = prefix {
1326                                if !key_str.starts_with(pfx.as_str()) {
1327                                    continue;
1328                                }
1329                            }
1330                            let entity_id = entity.id.raw();
1331                            match latest.get(key_str) {
1332                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
1333                                _ => {
1334                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
1335                                }
1336                            }
1337                        }
1338                    }
1339                }
1340                if as_json {
1341                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
1342                    for (key, (_, _, val)) in latest {
1343                        let relative = match prefix {
1344                            Some(pfx) if key == *pfx => "",
1345                            Some(pfx) => key
1346                                .strip_prefix(pfx.as_str())
1347                                .and_then(|tail| tail.strip_prefix('.'))
1348                                .unwrap_or(key.as_str()),
1349                            None => key.as_str(),
1350                        };
1351                        insert_config_json_path(
1352                            &mut tree,
1353                            relative,
1354                            crate::presentation::entity_json::storage_value_to_json(&val),
1355                        );
1356                    }
1357                    return Ok(show_config_json_result(query, mode, prefix, tree));
1358                }
1359                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1360                for (_, key_val, val) in latest.into_values() {
1361                    let mut record = UnifiedRecord::new();
1362                    record.set("key", key_val);
1363                    record.set("value", val);
1364                    result.push(record);
1365                }
1366                Ok(RuntimeQueryResult {
1367                    query: query.to_string(),
1368                    mode,
1369                    statement: "show_config",
1370                    engine: "runtime-config",
1371                    result,
1372                    affected_rows: 0,
1373                    statement_type: "select",
1374                    bookmark: None,
1375                    notice: None,
1376                })
1377            }
1378            // Session-local multi-tenancy handle (Phase 2.5.3).
1379            //
1380            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
1381            // the thread-local; SHOW TENANT returns it. Paired with the
1382            // CURRENT_TENANT() scalar for use in RLS policies.
1383            QueryExpr::SetTenant(ref value) => {
1384                match value {
1385                    Some(id) => set_current_tenant(id.clone()),
1386                    None => clear_current_tenant(),
1387                }
1388                Ok(RuntimeQueryResult::ok_message(
1389                    query.to_string(),
1390                    &match value {
1391                        Some(id) => format!("tenant set: {id}"),
1392                        None => "tenant cleared".to_string(),
1393                    },
1394                    "set_tenant",
1395                ))
1396            }
1397            QueryExpr::ShowTenant => {
1398                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
1399                let mut record = UnifiedRecord::new();
1400                record.set(
1401                    "tenant",
1402                    current_tenant().map(Value::text).unwrap_or(Value::Null),
1403                );
1404                result.push(record);
1405                Ok(RuntimeQueryResult {
1406                    query: query.to_string(),
1407                    mode,
1408                    statement: "show_tenant",
1409                    engine: "runtime-tenant",
1410                    result,
1411                    affected_rows: 0,
1412                    statement_type: "select",
1413                    bookmark: None,
1414                    notice: None,
1415                })
1416            }
1417            // Transaction control (Phase 2.3 PG parity).
1418            //
1419            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
1420            // the current connection's id. COMMIT/ROLLBACK release it through
1421            // the `SnapshotManager` so future snapshots see the correct set of
1422            // active/aborted transactions.
1423            //
1424            // Tuple stamping (xmin/xmax) and read-path visibility filtering
1425            // land in Phase 2.3.2 — this dispatch only manages the snapshot
1426            // registry. Statements running outside a TxnContext still behave
1427            // as autocommit (xid=0 → visible to every snapshot).
1428            QueryExpr::TransactionControl(ref ctl) => {
1429                use crate::storage::query::ast::TxnControl;
1430                use crate::storage::transaction::snapshot::{TxnContext, Xid};
1431                use crate::storage::transaction::IsolationLevel;
1432
1433                // Phase 2.3 keys transactions by a thread-local connection id.
1434                // The stdio/gRPC paths wire a real per-connection id later;
1435                // for embedded use (one RedDBRuntime per process-ish caller)
1436                // we fall back to a deterministic placeholder.
1437                let conn_id = current_connection_id();
1438
1439                let (kind, msg) = match ctl {
1440                    TxnControl::Begin(requested_isolation) => {
1441                        let isolation = requested_isolation
1442                            .map(IsolationLevel::from)
1443                            .unwrap_or(IsolationLevel::SnapshotIsolation);
1444                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1445                        let xid = mgr.begin();
1446                        if isolation == IsolationLevel::Serializable {
1447                            mgr.begin_serializable(xid);
1448                        }
1449                        let snapshot = mgr.snapshot(xid);
1450                        let ctx = TxnContext {
1451                            xid,
1452                            isolation,
1453                            snapshot,
1454                            savepoints: Vec::new(),
1455                            released_sub_xids: Vec::new(),
1456                        };
1457                        self.inner.tx_contexts.write().insert(conn_id, ctx);
1458                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
1459                    }
1460                    TxnControl::Commit => {
1461                        // SET LOCAL TENANT ends with the transaction.
1462                        self.inner.tx_local_tenants.write().remove(&conn_id);
1463                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
1464                        match ctx {
1465                            Some(ctx) => {
1466                                let mut own_xids = std::collections::HashSet::new();
1467                                own_xids.insert(ctx.xid);
1468                                for (_, sub) in &ctx.savepoints {
1469                                    own_xids.insert(*sub);
1470                                }
1471                                for sub in &ctx.released_sub_xids {
1472                                    own_xids.insert(*sub);
1473                                }
1474                                if let Err(err) = self.check_table_row_write_conflicts(
1475                                    conn_id,
1476                                    &ctx.snapshot,
1477                                    &own_xids,
1478                                    ctx.isolation,
1479                                ) {
1480                                    for (_, sub) in &ctx.savepoints {
1481                                        self.inner.snapshot_manager.rollback(*sub);
1482                                    }
1483                                    for sub in &ctx.released_sub_xids {
1484                                        self.inner.snapshot_manager.rollback(*sub);
1485                                    }
1486                                    self.inner.snapshot_manager.rollback(ctx.xid);
1487                                    self.revive_pending_versioned_updates(conn_id);
1488                                    self.revive_pending_tombstones(conn_id);
1489                                    self.discard_pending_kv_watch_events(conn_id);
1490                                    self.discard_pending_queue_wakes(conn_id);
1491                                    self.discard_pending_store_wal_actions(conn_id);
1492                                    self.release_pending_claim_locks(conn_id);
1493                                    return Err(err);
1494                                }
1495                                if let Err(err) = self.check_queue_dedup_write_conflicts(
1496                                    conn_id,
1497                                    &ctx.snapshot,
1498                                    &own_xids,
1499                                ) {
1500                                    for (_, sub) in &ctx.savepoints {
1501                                        self.inner.snapshot_manager.rollback(*sub);
1502                                    }
1503                                    for sub in &ctx.released_sub_xids {
1504                                        self.inner.snapshot_manager.rollback(*sub);
1505                                    }
1506                                    self.inner.snapshot_manager.rollback(ctx.xid);
1507                                    self.revive_pending_versioned_updates(conn_id);
1508                                    self.revive_pending_tombstones(conn_id);
1509                                    self.discard_pending_queue_dedup(conn_id);
1510                                    self.discard_pending_kv_watch_events(conn_id);
1511                                    self.discard_pending_queue_wakes(conn_id);
1512                                    self.discard_pending_store_wal_actions(conn_id);
1513                                    self.release_pending_claim_locks(conn_id);
1514                                    return Err(err);
1515                                }
1516                                self.restore_pending_write_stamps(conn_id);
1517                                if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
1518                                    for (_, sub) in &ctx.savepoints {
1519                                        self.inner.snapshot_manager.rollback(*sub);
1520                                    }
1521                                    for sub in &ctx.released_sub_xids {
1522                                        self.inner.snapshot_manager.rollback(*sub);
1523                                    }
1524                                    self.inner.snapshot_manager.rollback(ctx.xid);
1525                                    self.revive_pending_versioned_updates(conn_id);
1526                                    self.revive_pending_tombstones(conn_id);
1527                                    self.discard_pending_queue_dedup(conn_id);
1528                                    self.discard_pending_kv_watch_events(conn_id);
1529                                    self.discard_pending_queue_wakes(conn_id);
1530                                    self.release_pending_claim_locks(conn_id);
1531                                    return Err(err);
1532                                }
1533                                // Phase 2.3.2e: commit every open sub-xid
1534                                // so they also become visible. Their
1535                                // work is promoted to the parent txn's
1536                                // result exactly like a RELEASE would
1537                                // have done.
1538                                for (_, sub) in &ctx.savepoints {
1539                                    self.inner.snapshot_manager.commit(*sub);
1540                                }
1541                                for sub in &ctx.released_sub_xids {
1542                                    self.inner.snapshot_manager.commit(*sub);
1543                                }
1544                                self.inner.snapshot_manager.commit(ctx.xid);
1545                                self.finalize_pending_versioned_updates(conn_id);
1546                                self.finalize_pending_tombstones(conn_id);
1547                                self.finalize_pending_queue_dedup(conn_id);
1548                                self.finalize_pending_kv_watch_events(conn_id);
1549                                self.finalize_pending_queue_wakes(conn_id);
1550                                self.release_pending_claim_locks(conn_id);
1551                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
1552                            }
1553                            None => (
1554                                "commit",
1555                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
1556                            ),
1557                        }
1558                    }
1559                    TxnControl::Rollback => {
1560                        self.inner.tx_local_tenants.write().remove(&conn_id);
1561                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
1562                        match ctx {
1563                            Some(ctx) => {
1564                                // Phase 2.3.2e: abort every open sub-xid
1565                                // too so their writes stay hidden.
1566                                for (_, sub) in &ctx.savepoints {
1567                                    self.inner.snapshot_manager.rollback(*sub);
1568                                }
1569                                for sub in &ctx.released_sub_xids {
1570                                    self.inner.snapshot_manager.rollback(*sub);
1571                                }
1572                                self.inner.snapshot_manager.rollback(ctx.xid);
1573                                // Phase 2.3.2b: tuples that the txn had
1574                                // xmax-stamped become live again — wipe xmax
1575                                // back to 0 so later snapshots see them.
1576                                self.revive_pending_versioned_updates(conn_id);
1577                                self.revive_pending_tombstones(conn_id);
1578                                self.discard_pending_queue_dedup(conn_id);
1579                                self.discard_pending_kv_watch_events(conn_id);
1580                                self.discard_pending_queue_wakes(conn_id);
1581                                self.discard_pending_store_wal_actions(conn_id);
1582                                self.release_pending_claim_locks(conn_id);
1583                                ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid))
1584                            }
1585                            None => (
1586                                "rollback",
1587                                "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
1588                            ),
1589                        }
1590                    }
1591                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
1592                    // SAVEPOINT allocates a fresh xid and pushes it
1593                    // onto the per-txn stack so subsequent writes can
1594                    // be selectively rolled back. RELEASE pops without
1595                    // aborting; ROLLBACK TO aborts the sub-xid (and
1596                    // any nested ones) + revives their tombstones.
1597                    TxnControl::Savepoint(name) => {
1598                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1599                        let mut guard = self.inner.tx_contexts.write();
1600                        match guard.get_mut(&conn_id) {
1601                            Some(ctx) => {
1602                                let sub = mgr.begin();
1603                                ctx.savepoints.push((name.clone(), sub));
1604                                ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}"))
1605                            }
1606                            None => (
1607                                "savepoint",
1608                                "SAVEPOINT outside transaction — no-op".to_string(),
1609                            ),
1610                        }
1611                    }
1612                    TxnControl::ReleaseSavepoint(name) => {
1613                        let mut guard = self.inner.tx_contexts.write();
1614                        match guard.get_mut(&conn_id) {
1615                            Some(ctx) => {
1616                                let pos = ctx
1617                                    .savepoints
1618                                    .iter()
1619                                    .position(|(n, _)| n == name)
1620                                    .ok_or_else(|| {
1621                                        RedDBError::Internal(format!(
1622                                            "savepoint {name} does not exist"
1623                                        ))
1624                                    })?;
1625                                // RELEASE pops the named savepoint and
1626                                // any nested ones. Their sub-xids move
1627                                // to `released_sub_xids` so they commit
1628                                // (or roll back) alongside the parent
1629                                // xid — PG semantics: released
1630                                // savepoints still contribute their
1631                                // work, but their names are gone.
1632                                let released = ctx.savepoints.len() - pos;
1633                                let popped: Vec<Xid> = ctx
1634                                    .savepoints
1635                                    .split_off(pos)
1636                                    .into_iter()
1637                                    .map(|(_, x)| x)
1638                                    .collect();
1639                                ctx.released_sub_xids.extend(popped);
1640                                (
1641                                    "release_savepoint",
1642                                    format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
1643                                )
1644                            }
1645                            None => (
1646                                "release_savepoint",
1647                                "RELEASE outside transaction — no-op".to_string(),
1648                            ),
1649                        }
1650                    }
1651                    TxnControl::RollbackToSavepoint(name) => {
1652                        let mgr = Arc::clone(&self.inner.snapshot_manager);
1653                        // Splice out the savepoint + nested ones under
1654                        // a narrow lock, then run the snapshot-manager
1655                        // + tombstone side-effects without the tx map
1656                        // held so nothing re-enters.
1657                        let drop_result: Option<(Xid, Vec<Xid>)> = {
1658                            let mut guard = self.inner.tx_contexts.write();
1659                            if let Some(ctx) = guard.get_mut(&conn_id) {
1660                                let pos = ctx
1661                                    .savepoints
1662                                    .iter()
1663                                    .position(|(n, _)| n == name)
1664                                    .ok_or_else(|| {
1665                                        RedDBError::Internal(format!(
1666                                            "savepoint {name} does not exist"
1667                                        ))
1668                                    })?;
1669                                let savepoint_xid = ctx.savepoints[pos].1;
1670                                let aborted: Vec<Xid> = ctx
1671                                    .savepoints
1672                                    .split_off(pos)
1673                                    .into_iter()
1674                                    .map(|(_, x)| x)
1675                                    .collect();
1676                                Some((savepoint_xid, aborted))
1677                            } else {
1678                                None
1679                            }
1680                        };
1681
1682                        match drop_result {
1683                            Some((savepoint_xid, aborted)) => {
1684                                for x in &aborted {
1685                                    mgr.rollback(*x);
1686                                }
1687                                let reverted_updates =
1688                                    self.revive_versioned_updates_since(conn_id, savepoint_xid);
1689                                let revived = self.revive_tombstones_since(conn_id, savepoint_xid);
1690                                (
1691                                    "rollback_to_savepoint",
1692                                    format!(
1693                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
1694                                        aborted.len(),
1695                                    ),
1696                                )
1697                            }
1698                            None => (
1699                                "rollback_to_savepoint",
1700                                "ROLLBACK TO outside transaction — no-op".to_string(),
1701                            ),
1702                        }
1703                    }
1704                };
1705                Ok(RuntimeQueryResult::ok_message(
1706                    query.to_string(),
1707                    &msg,
1708                    kind,
1709                ))
1710            }
1711            // Schema + Sequence DDL (Phase 1.3 PG parity).
1712            //
1713            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
1714            // just registers the name in `red_config` under `schema.{name}`.
1715            // Table lookups still happen by collection name; clients using
1716            // `schema.table` qualified names collapse to collection `schema.table`.
1717            //
1718            // Sequences persist a 64-bit counter + metadata (start, increment)
1719            // in `red_config` under `sequence.{name}.*`. Scalar callers
1720            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
1721            // once we have a proper mutating-function dispatch path; for now the
1722            // DDL just establishes the catalog entry so clients don't error.
1723            QueryExpr::CreateSchema(ref q) => {
1724                let store = self.inner.db.store();
1725                let key = format!("schema.{}", q.name);
1726                if store.get_config(&key).is_some() {
1727                    if q.if_not_exists {
1728                        return Ok(RuntimeQueryResult::ok_message(
1729                            query.to_string(),
1730                            &format!("schema {} already exists — skipped", q.name),
1731                            "create_schema",
1732                        ));
1733                    }
1734                    return Err(RedDBError::Internal(format!(
1735                        "schema {} already exists",
1736                        q.name
1737                    )));
1738                }
1739                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
1740                Ok(RuntimeQueryResult::ok_message(
1741                    query.to_string(),
1742                    &format!("schema {} created", q.name),
1743                    "create_schema",
1744                ))
1745            }
1746            QueryExpr::DropSchema(ref q) => {
1747                let store = self.inner.db.store();
1748                let key = format!("schema.{}", q.name);
1749                let existed = store.get_config(&key).is_some();
1750                if !existed && !q.if_exists {
1751                    return Err(RedDBError::Internal(format!(
1752                        "schema {} does not exist",
1753                        q.name
1754                    )));
1755                }
1756                // Remove marker from red_config via set to null.
1757                store.set_config_tree(&key, &crate::serde_json::Value::Null);
1758                let suffix = if q.cascade {
1759                    " (CASCADE accepted — tables untouched)"
1760                } else {
1761                    ""
1762                };
1763                Ok(RuntimeQueryResult::ok_message(
1764                    query.to_string(),
1765                    &format!("schema {} dropped{}", q.name, suffix),
1766                    "drop_schema",
1767                ))
1768            }
1769            QueryExpr::CreateSequence(ref q) => {
1770                let store = self.inner.db.store();
1771                let base = format!("sequence.{}", q.name);
1772                let start_key = format!("{base}.start");
1773                let incr_key = format!("{base}.increment");
1774                let curr_key = format!("{base}.current");
1775                if store.get_config(&start_key).is_some() {
1776                    if q.if_not_exists {
1777                        return Ok(RuntimeQueryResult::ok_message(
1778                            query.to_string(),
1779                            &format!("sequence {} already exists — skipped", q.name),
1780                            "create_sequence",
1781                        ));
1782                    }
1783                    return Err(RedDBError::Internal(format!(
1784                        "sequence {} already exists",
1785                        q.name
1786                    )));
1787                }
1788                // Persist start + increment, and set current so the first
1789                // nextval returns `start`.
1790                let initial_current = q.start - q.increment;
1791                store.set_config_tree(
1792                    &start_key,
1793                    &crate::serde_json::Value::Number(q.start as f64),
1794                );
1795                store.set_config_tree(
1796                    &incr_key,
1797                    &crate::serde_json::Value::Number(q.increment as f64),
1798                );
1799                store.set_config_tree(
1800                    &curr_key,
1801                    &crate::serde_json::Value::Number(initial_current as f64),
1802                );
1803                Ok(RuntimeQueryResult::ok_message(
1804                    query.to_string(),
1805                    &format!(
1806                        "sequence {} created (start={}, increment={})",
1807                        q.name, q.start, q.increment
1808                    ),
1809                    "create_sequence",
1810                ))
1811            }
1812            QueryExpr::DropSequence(ref q) => {
1813                let store = self.inner.db.store();
1814                let base = format!("sequence.{}", q.name);
1815                let existed = store.get_config(&format!("{base}.start")).is_some();
1816                if !existed && !q.if_exists {
1817                    return Err(RedDBError::Internal(format!(
1818                        "sequence {} does not exist",
1819                        q.name
1820                    )));
1821                }
1822                for k in ["start", "increment", "current"] {
1823                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
1824                }
1825                Ok(RuntimeQueryResult::ok_message(
1826                    query.to_string(),
1827                    &format!("sequence {} dropped", q.name),
1828                    "drop_sequence",
1829                ))
1830            }
1831            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
1832            //
1833            // The view definition is stored in-memory on RuntimeInner (not
1834            // persisted). SELECTs that reference the view name will substitute
1835            // the stored `QueryExpr` via `resolve_view_reference` during
1836            // planning (same entry point used by table-name resolution).
1837            //
1838            // Materialized views additionally allocate a slot in
1839            // `MaterializedViewCache`; a REFRESH repopulates that slot.
1840            QueryExpr::CreateView(ref q) => {
1841                let mut views = self.inner.views.write();
1842                if views.contains_key(&q.name) && !q.or_replace {
1843                    if q.if_not_exists {
1844                        return Ok(RuntimeQueryResult::ok_message(
1845                            query.to_string(),
1846                            &format!("view {} already exists — skipped", q.name),
1847                            "create_view",
1848                        ));
1849                    }
1850                    return Err(RedDBError::Internal(format!(
1851                        "view {} already exists",
1852                        q.name
1853                    )));
1854                }
1855                views.insert(q.name.clone(), Arc::new(q.clone()));
1856                drop(views);
1857
1858                // Materialized view: register cache slot (data is empty until REFRESH).
1859                if q.materialized {
1860                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
1861                    let refresh = match q.refresh_every_ms {
1862                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
1863                        None => RefreshPolicy::Manual,
1864                    };
1865                    let dependencies = collect_table_refs(&q.query);
1866                    let def = MaterializedViewDef {
1867                        name: q.name.clone(),
1868                        query: format!("<parsed view {}>", q.name),
1869                        dependencies: dependencies.clone(),
1870                        refresh,
1871                        retention_duration_ms: q.retention_duration_ms,
1872                    };
1873                    self.inner.materialized_views.write().register(def);
1874
1875                    // Issue #593 slice 9a — persist the descriptor to
1876                    // the system catalog so the definition survives a
1877                    // restart. Upsert semantics (delete-then-insert by
1878                    // name) keep the catalog free of duplicate rows
1879                    // across `CREATE OR REPLACE` churn.
1880                    let descriptor =
1881                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
1882                            name: q.name.clone(),
1883                            source_sql: query.to_string(),
1884                            source_collections: dependencies,
1885                            refresh_every_ms: q.refresh_every_ms,
1886                            retention_duration_ms: q.retention_duration_ms,
1887                        };
1888                    let store = self.inner.db.store();
1889                    crate::runtime::continuous_materialized_view::persist_descriptor(
1890                        store.as_ref(),
1891                        &descriptor,
1892                    )?;
1893
1894                    // Issue #594 slice 9b — provision a Table-shaped
1895                    // backing collection named after the view. The
1896                    // rewriter skips materialized views (see
1897                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
1898                    // resolves to this collection directly. Empty
1899                    // until REFRESH wires through it in 9c.
1900                    self.ensure_materialized_view_backing(&q.name)?;
1901                }
1902                // Plan cache may have cached a plan that didn't know about this
1903                // view — invalidate so future references pick up the new binding.
1904                // Result cache gets flushed too: OR REPLACE must not serve a
1905                // prior execution of the obsolete body.
1906                self.invalidate_plan_cache();
1907                self.invalidate_result_cache();
1908
1909                Ok(RuntimeQueryResult::ok_message(
1910                    query.to_string(),
1911                    &format!(
1912                        "{}view {} created",
1913                        if q.materialized { "materialized " } else { "" },
1914                        q.name
1915                    ),
1916                    "create_view",
1917                ))
1918            }
1919            QueryExpr::DropView(ref q) => {
1920                let mut views = self.inner.views.write();
1921                let removed = views.remove(&q.name);
1922                let existed = removed.is_some();
1923                let removed_materialized =
1924                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
1925                drop(views);
1926                if q.materialized || existed {
1927                    // Try the materialised cache too — silent if absent.
1928                    self.inner.materialized_views.write().remove(&q.name);
1929                    // Issue #593 slice 9a — remove any persisted
1930                    // catalog row. Idempotent: a no-op when the view
1931                    // was never materialized (no row was ever written).
1932                    let store = self.inner.db.store();
1933                    crate::runtime::continuous_materialized_view::remove_by_name(
1934                        store.as_ref(),
1935                        &q.name,
1936                    )?;
1937                }
1938                // Issue #594 slice 9b — drop the backing collection
1939                // that was provisioned at CREATE time. Only mat views
1940                // ever had one; regular views never did.
1941                if removed_materialized || q.materialized {
1942                    self.drop_materialized_view_backing(&q.name)?;
1943                }
1944                // Drop any plan / result cache entries that baked the
1945                // view body into their QueryExpr.
1946                self.invalidate_plan_cache();
1947                self.invalidate_result_cache();
1948                if !existed && !q.if_exists {
1949                    return Err(RedDBError::Internal(format!(
1950                        "view {} does not exist",
1951                        q.name
1952                    )));
1953                }
1954                self.invalidate_plan_cache();
1955                Ok(RuntimeQueryResult::ok_message(
1956                    query.to_string(),
1957                    &format!("view {} dropped", q.name),
1958                    "drop_view",
1959                ))
1960            }
1961            QueryExpr::RefreshMaterializedView(ref q) => {
1962                // Look up the view definition, execute its underlying query,
1963                // and stash the serialized result in the materialised cache.
1964                let view = {
1965                    let views = self.inner.views.read();
1966                    views.get(&q.name).cloned()
1967                };
1968                let view = match view {
1969                    Some(v) => v,
1970                    None => {
1971                        return Err(RedDBError::Internal(format!(
1972                            "view {} does not exist",
1973                            q.name
1974                        )))
1975                    }
1976                };
1977                if !view.materialized {
1978                    return Err(RedDBError::Internal(format!(
1979                        "view {} is not materialized — REFRESH requires \
1980                         CREATE MATERIALIZED VIEW",
1981                        q.name
1982                    )));
1983                }
1984                // Execute the underlying query fresh.
1985                let started = std::time::Instant::now();
1986                let now_ms = std::time::SystemTime::now()
1987                    .duration_since(std::time::UNIX_EPOCH)
1988                    .map(|d| d.as_millis() as u64)
1989                    .unwrap_or(0);
1990                match self.execute_query_expr((*view.query).clone()) {
1991                    Ok(inner_result) => {
1992                        // Issue #595 slice 9c — atomically replace the
1993                        // backing collection's contents under a single
1994                        // WAL group. Concurrent SELECT from the view
1995                        // sees either the prior or new contents, never
1996                        // partial. A crash before the WAL commit lands
1997                        // leaves the prior contents intact on recovery.
1998                        let entities =
1999                            view_records_to_entities(&q.name, &inner_result.result.records);
2000                        let row_count = entities.len() as u64;
2001                        let store = self.inner.db.store();
2002                        let serialized_records = match store.refresh_collection(&q.name, entities) {
2003                            Ok(records) => records,
2004                            Err(err) => {
2005                                let duration_ms = started.elapsed().as_millis() as u64;
2006                                let msg = err.to_string();
2007                                self.inner
2008                                    .materialized_views
2009                                    .write()
2010                                    .record_refresh_failure(
2011                                        &q.name,
2012                                        msg.clone(),
2013                                        duration_ms,
2014                                        now_ms,
2015                                    );
2016                                return Err(RedDBError::Internal(format!(
2017                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
2018                                    q.name
2019                                )));
2020                            }
2021                        };
2022
2023                        // Issue #596 slice 9d — emit a Refresh
2024                        // ChangeRecord into the logical-WAL spool so
2025                        // replicas deterministically replay the same
2026                        // backing-collection contents via
2027                        // `LogicalChangeApplier::apply_record`.
2028                        if let Some(ref primary) = self.inner.db.replication {
2029                            let lsn = self.inner.cdc.emit(
2030                                crate::replication::cdc::ChangeOperation::Refresh,
2031                                &q.name,
2032                                0,
2033                                "refresh",
2034                            );
2035                            self.invalidate_result_cache_for_table(&q.name);
2036                            let timestamp = std::time::SystemTime::now()
2037                                .duration_since(std::time::UNIX_EPOCH)
2038                                .unwrap_or_default()
2039                                .as_millis() as u64;
2040                            let record = ChangeRecord::for_refresh(
2041                                lsn,
2042                                timestamp,
2043                                q.name.clone(),
2044                                serialized_records,
2045                            )
2046                            .with_term(self.current_replication_term());
2047                            let encoded = record.encode();
2048                            primary.append_logical_record(record.lsn, encoded);
2049                        }
2050
2051                        let duration_ms = started.elapsed().as_millis() as u64;
2052                        let serialized = format!("{:?}", inner_result.result);
2053                        self.inner
2054                            .materialized_views
2055                            .write()
2056                            .record_refresh_success(
2057                                &q.name,
2058                                serialized.into_bytes(),
2059                                row_count,
2060                                duration_ms,
2061                                now_ms,
2062                            );
2063                        // SELECT FROM v now reads through the rewriter
2064                        // skip into the backing collection — drop the
2065                        // result cache so prior empty-backing reads
2066                        // don't shadow the new contents.
2067                        self.invalidate_result_cache();
2068                        Ok(RuntimeQueryResult::ok_message(
2069                            query.to_string(),
2070                            &format!("materialized view {} refreshed", q.name),
2071                            "refresh_materialized_view",
2072                        ))
2073                    }
2074                    Err(err) => {
2075                        let duration_ms = started.elapsed().as_millis() as u64;
2076                        let msg = err.to_string();
2077                        self.inner
2078                            .materialized_views
2079                            .write()
2080                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
2081                        Err(err)
2082                    }
2083                }
2084            }
2085            // Row Level Security (Phase 2.5 PG parity).
2086            //
2087            // Policies live in an in-memory registry keyed by (table, name).
2088            // Enforcement (AND-ing the policy's USING clause into every
2089            // query's WHERE for the table) arrives in Phase 2.5.2 via the
2090            // filter compiler; this dispatch only manages the catalog.
2091            QueryExpr::CreatePolicy(ref q) => {
2092                let key = (q.table.clone(), q.name.clone());
2093                self.inner
2094                    .rls_policies
2095                    .write()
2096                    .insert(key, Arc::new(q.clone()));
2097                self.invalidate_plan_cache();
2098                // Issue #120 — surface policy names in the
2099                // schema-vocabulary so AskPipeline (#121) can resolve
2100                // a policy reference back to its table.
2101                self.schema_vocabulary_apply(
2102                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
2103                        collection: q.table.clone(),
2104                        policy: q.name.clone(),
2105                    },
2106                );
2107                Ok(RuntimeQueryResult::ok_message(
2108                    query.to_string(),
2109                    &format!("policy {} on {} created", q.name, q.table),
2110                    "create_policy",
2111                ))
2112            }
2113            QueryExpr::DropPolicy(ref q) => {
2114                let removed = self
2115                    .inner
2116                    .rls_policies
2117                    .write()
2118                    .remove(&(q.table.clone(), q.name.clone()))
2119                    .is_some();
2120                if !removed && !q.if_exists {
2121                    return Err(RedDBError::Internal(format!(
2122                        "policy {} on {} does not exist",
2123                        q.name, q.table
2124                    )));
2125                }
2126                self.invalidate_plan_cache();
2127                // Issue #120 — keep the schema-vocabulary policy
2128                // entry in sync.
2129                self.schema_vocabulary_apply(
2130                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
2131                        collection: q.table.clone(),
2132                        policy: q.name.clone(),
2133                    },
2134                );
2135                Ok(RuntimeQueryResult::ok_message(
2136                    query.to_string(),
2137                    &format!("policy {} on {} dropped", q.name, q.table),
2138                    "drop_policy",
2139                ))
2140            }
2141            // Foreign Data Wrappers (Phase 3.2 PG parity).
2142            //
2143            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
2144            // `ForeignTableRegistry`. The read path consults that registry
2145            // before dispatching a SELECT — when the table name matches a
2146            // registered foreign table, we forward the scan to the wrapper
2147            // and skip the normal collection lookup.
2148            //
2149            // Phase 3.2 is in-memory only; persistence across restarts is a
2150            // 3.2.2 follow-up that mirrors the view registry pattern.
2151            QueryExpr::CreateServer(ref q) => {
2152                use crate::storage::fdw::FdwOptions;
2153                let registry = Arc::clone(&self.inner.foreign_tables);
2154                if registry.server(&q.name).is_some() {
2155                    if q.if_not_exists {
2156                        return Ok(RuntimeQueryResult::ok_message(
2157                            query.to_string(),
2158                            &format!("server {} already exists — skipped", q.name),
2159                            "create_server",
2160                        ));
2161                    }
2162                    return Err(RedDBError::Internal(format!(
2163                        "server {} already exists",
2164                        q.name
2165                    )));
2166                }
2167                let mut opts = FdwOptions::new();
2168                for (k, v) in &q.options {
2169                    opts.values.insert(k.clone(), v.clone());
2170                }
2171                registry
2172                    .create_server(&q.name, &q.wrapper, opts)
2173                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2174                Ok(RuntimeQueryResult::ok_message(
2175                    query.to_string(),
2176                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
2177                    "create_server",
2178                ))
2179            }
2180            QueryExpr::DropServer(ref q) => {
2181                let existed = self.inner.foreign_tables.drop_server(&q.name);
2182                if !existed && !q.if_exists {
2183                    return Err(RedDBError::Internal(format!(
2184                        "server {} does not exist",
2185                        q.name
2186                    )));
2187                }
2188                Ok(RuntimeQueryResult::ok_message(
2189                    query.to_string(),
2190                    &format!(
2191                        "server {} dropped{}",
2192                        q.name,
2193                        if q.cascade { " (cascade)" } else { "" }
2194                    ),
2195                    "drop_server",
2196                ))
2197            }
2198            QueryExpr::CreateForeignTable(ref q) => {
2199                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
2200                let registry = Arc::clone(&self.inner.foreign_tables);
2201                if registry.foreign_table(&q.name).is_some() {
2202                    if q.if_not_exists {
2203                        return Ok(RuntimeQueryResult::ok_message(
2204                            query.to_string(),
2205                            &format!("foreign table {} already exists — skipped", q.name),
2206                            "create_foreign_table",
2207                        ));
2208                    }
2209                    return Err(RedDBError::Internal(format!(
2210                        "foreign table {} already exists",
2211                        q.name
2212                    )));
2213                }
2214                let mut opts = FdwOptions::new();
2215                for (k, v) in &q.options {
2216                    opts.values.insert(k.clone(), v.clone());
2217                }
2218                let columns: Vec<ForeignColumn> = q
2219                    .columns
2220                    .iter()
2221                    .map(|c| ForeignColumn {
2222                        name: c.name.clone(),
2223                        data_type: c.data_type.clone(),
2224                        not_null: c.not_null,
2225                    })
2226                    .collect();
2227                registry
2228                    .create_foreign_table(ForeignTable {
2229                        name: q.name.clone(),
2230                        server_name: q.server.clone(),
2231                        columns,
2232                        options: opts,
2233                    })
2234                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2235                self.invalidate_plan_cache();
2236                Ok(RuntimeQueryResult::ok_message(
2237                    query.to_string(),
2238                    &format!("foreign table {} created (server {})", q.name, q.server),
2239                    "create_foreign_table",
2240                ))
2241            }
2242            QueryExpr::DropForeignTable(ref q) => {
2243                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
2244                if !existed && !q.if_exists {
2245                    return Err(RedDBError::Internal(format!(
2246                        "foreign table {} does not exist",
2247                        q.name
2248                    )));
2249                }
2250                self.invalidate_plan_cache();
2251                Ok(RuntimeQueryResult::ok_message(
2252                    query.to_string(),
2253                    &format!("foreign table {} dropped", q.name),
2254                    "drop_foreign_table",
2255                ))
2256            }
2257            // COPY table FROM 'path' (Phase 1.5 PG parity).
2258            //
2259            // Stream CSV rows through the shared `CsvImporter`. The collection
2260            // is auto-created on first insert (via `insert_auto`-style path);
2261            // VACUUM/ANALYZE afterwards is up to the caller.
2262            QueryExpr::CopyFrom(ref q) => {
2263                use crate::storage::import::{CsvConfig, CsvImporter};
2264                let store = self.inner.db.store();
2265                let cfg = CsvConfig {
2266                    collection: q.table.clone(),
2267                    has_header: q.has_header,
2268                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
2269                    ..CsvConfig::default()
2270                };
2271                let importer = CsvImporter::new(cfg);
2272                let stats = importer
2273                    .import_file(&q.path, store.as_ref())
2274                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
2275                // Tables are written → invalidate cached plans / result cache.
2276                self.note_table_write(&q.table);
2277                Ok(RuntimeQueryResult::ok_message(
2278                    query.to_string(),
2279                    &format!(
2280                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
2281                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
2282                    ),
2283                    "copy_from",
2284                ))
2285            }
2286            // Maintenance commands (Phase 1.2 PG parity).
2287            //
2288            // - VACUUM [FULL] [table]: refreshes planner stats for the target
2289            //   collection(s) and — when FULL — triggers a full pager persist
2290            //   (flushes dirty pages + fsync). Also invalidates the result cache
2291            //   so subsequent reads re-execute against the freshly compacted
2292            //   storage. RedDB's segment/btree GC runs continuously via the
2293            //   background lifecycle; explicit space reclamation for sealed
2294            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
2295            // - ANALYZE [table]: reruns `analyze_collection` +
2296            //   `persist_table_stats` via `refresh_table_planner_stats` so the
2297            //   planner has fresh histograms, distinct estimates, null counts.
2298            //
2299            // Both commands accept an optional target; omitting the target
2300            // iterates every collection in the store.
2301            QueryExpr::MaintenanceCommand(ref cmd) => {
2302                use crate::storage::query::ast::MaintenanceCommand as Mc;
2303                let store = self.inner.db.store();
2304                let (kind, msg) = match cmd {
2305                    Mc::Analyze { target } => {
2306                        let targets: Vec<String> = match target {
2307                            Some(t) => vec![t.clone()],
2308                            None => store.list_collections(),
2309                        };
2310                        for t in &targets {
2311                            self.refresh_table_planner_stats(t);
2312                        }
2313                        (
2314                            "analyze",
2315                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
2316                        )
2317                    }
2318                    Mc::Vacuum { target, full } => {
2319                        let targets: Vec<String> = match target {
2320                            Some(t) => vec![t.clone()],
2321                            None => store.list_collections(),
2322                        };
2323                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
2324                        let mut vacuum_stats =
2325                            crate::storage::unified::store::MvccVacuumStats::default();
2326                        for t in &targets {
2327                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
2328                                RedDBError::Internal(format!(
2329                                    "VACUUM MVCC history failed for {t}: {e}"
2330                                ))
2331                            })?;
2332                            if stats.reclaimed_versions > 0 {
2333                                self.rebuild_runtime_indexes_for_table(t)?;
2334                            }
2335                            vacuum_stats.add(&stats);
2336                        }
2337                        self.inner.snapshot_manager.prune_aborted(cutoff_xid);
2338                        // Stats refresh covers every target (same as ANALYZE).
2339                        for t in &targets {
2340                            self.refresh_table_planner_stats(t);
2341                        }
2342                        // FULL forces a pager persist (dirty-page flush + fsync).
2343                        // Regular VACUUM relies on the background writer / segment
2344                        // lifecycle so the command is non-blocking.
2345                        let persisted = if *full {
2346                            match store.persist() {
2347                                Ok(()) => true,
2348                                Err(e) => {
2349                                    return Err(RedDBError::Internal(format!(
2350                                        "VACUUM FULL persist failed: {e:?}"
2351                                    )));
2352                                }
2353                            }
2354                        } else {
2355                            false
2356                        };
2357                        // Result cache depended on pre-vacuum state.
2358                        self.invalidate_result_cache();
2359                        (
2360                            "vacuum",
2361                            format!(
2362                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2363                                if *full { " FULL" } else { "" },
2364                                targets.len(),
2365                                vacuum_stats.scanned_versions,
2366                                vacuum_stats.retained_versions,
2367                                vacuum_stats.reclaimed_versions,
2368                                vacuum_stats.retained_history_versions,
2369                                vacuum_stats.reclaimed_history_versions,
2370                                vacuum_stats.retained_tombstones,
2371                                vacuum_stats.reclaimed_tombstones,
2372                                if persisted {
2373                                    " (pages flushed to disk)"
2374                                } else {
2375                                    ""
2376                                }
2377                            ),
2378                        )
2379                    }
2380                };
2381                Ok(RuntimeQueryResult::ok_message(
2382                    query.to_string(),
2383                    &msg,
2384                    kind,
2385                ))
2386            }
2387            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2388            //
2389            // These hit the AuthStore directly. The statement frame /
2390            // privilege gate has already decided whether the caller may
2391            // even run the statement; here we just translate the AST into
2392            // AuthStore calls.
2393            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2394            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2395            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2396            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2397            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2398                self.execute_create_iam_policy(query, id, json)
2399            }
2400            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2401            QueryExpr::AttachPolicy {
2402                ref policy_id,
2403                ref principal,
2404            } => self.execute_attach_policy(query, policy_id, principal),
2405            QueryExpr::DetachPolicy {
2406                ref policy_id,
2407                ref principal,
2408            } => self.execute_detach_policy(query, policy_id, principal),
2409            QueryExpr::ShowPolicies { ref filter } => {
2410                self.execute_show_policies(query, filter.as_ref())
2411            }
2412            QueryExpr::ShowEffectivePermissions {
2413                ref user,
2414                ref resource,
2415            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2416            QueryExpr::SimulatePolicy {
2417                ref user,
2418                ref action,
2419                ref resource,
2420            } => self.execute_simulate_policy(query, user, action, resource),
2421            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2422            QueryExpr::MigratePolicyMode {
2423                ref target,
2424                dry_run,
2425            } => self.execute_migrate_policy_mode(query, target, dry_run),
2426            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2427            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2428            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2429            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2430            _ => Err(RedDBError::Query(
2431                "unsupported command in runtime dispatcher".to_string(),
2432            )),
2433        };
2434
2435        if !control_event_specs.is_empty() {
2436            let (outcome, reason) = match &query_result {
2437                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2438                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2439            };
2440            for spec in &control_event_specs {
2441                self.emit_control_event(
2442                    spec.kind,
2443                    outcome,
2444                    spec.action,
2445                    spec.resource.clone(),
2446                    reason.clone(),
2447                    spec.fields.clone(),
2448                )?;
2449            }
2450        }
2451
2452        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2453            self.emit_query_audit(
2454                query,
2455                plan,
2456                query_audit_started.elapsed().as_millis() as u64,
2457                result,
2458            );
2459        }
2460
2461        // Decrypt Value::Secret columns in-place before caching, so
2462        // cached results match the post-decrypt shape and repeat
2463        // queries skip the per-row AES-GCM pass.
2464        let mut query_result = query_result;
2465        if let Ok(ref mut result) = query_result {
2466            if result.statement_type == "select" {
2467                self.apply_secret_decryption(result);
2468            }
2469        }
2470
2471        // Cache SELECT results for 30s.
2472        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2473        // Large multi-row results (range scans, filtered scans) are rarely
2474        // repeated with the same literal values so the cache hit rate is near
2475        // zero while the clone cost (100 records × ~16 fields each) is high.
2476        // Aggregations (1 row) and point lookups (1 row) still benefit.
2477        if let Ok(ref result) = query_result {
2478            frame.write_result_cache(self, result, result_cache_scopes);
2479        }
2480
2481        query_result
2482    }
2483
2484    /// Execute a pre-parsed `QueryExpr` directly, bypassing SQL parsing and the
2485    /// plan cache. Used by the prepared-statement fast path so that `execute_prepared`
2486    /// calls pay zero parse + cache overhead.
2487    ///
2488    /// Applies secret decryption on SELECT results, identical to `execute_query`.
2489    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2490        let _config_snapshot_guard = ConfigSnapshotGuard::install(
2491            Arc::clone(&self.inner.db),
2492            self.inner.auth_store.read().clone(),
2493        );
2494        let _secret_store_guard = SecretStoreGuard::install(self.inner.auth_store.read().clone());
2495        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2496        // whose `tq.table` matches a registered view with the view's
2497        // underlying query. Safe to call even when no views are registered.
2498        let expr = self.rewrite_view_refs(expr);
2499
2500        self.validate_model_operations_before_auth(&expr)?;
2501        // Granular RBAC privilege check. Runs before dispatch so a
2502        // denied caller never reaches storage. Fail-closed: any error
2503        // resolving the action / resource produces PermissionDenied.
2504        if let Err(err) = self.check_query_privilege(&expr) {
2505            return Err(RedDBError::Query(format!("permission denied: {err}")));
2506        }
2507
2508        let statement = query_expr_name(&expr);
2509        let mode = detect_mode(statement);
2510        let query_str = statement;
2511
2512        let result = self.dispatch_expr(expr, query_str, mode)?;
2513        let mut r = result;
2514        if r.statement_type == "select" {
2515            self.apply_secret_decryption(&mut r);
2516        }
2517        Ok(r)
2518    }
2519
2520    pub(super) fn validate_model_operations_before_auth(
2521        &self,
2522        expr: &QueryExpr,
2523    ) -> RedDBResult<()> {
2524        use crate::catalog::CollectionModel;
2525        use crate::runtime::ddl::polymorphic_resolver;
2526        use crate::storage::query::ast::KvCommand;
2527
2528        let system_schema_target = match expr {
2529            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2530            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2531            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2532            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2533            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2534            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2535            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2536            _ => None,
2537        };
2538        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2539            return Err(RedDBError::Query("system schema is read-only".to_string()));
2540        }
2541
2542        let expected = match expr {
2543            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2544            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2545            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2546            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2547            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2548            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2549            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2550            QueryExpr::KvCommand(cmd) => {
2551                let (collection, model) = match cmd {
2552                    KvCommand::Put {
2553                        collection, model, ..
2554                    }
2555                    | KvCommand::Get {
2556                        collection, model, ..
2557                    }
2558                    | KvCommand::Incr {
2559                        collection, model, ..
2560                    }
2561                    | KvCommand::Cas {
2562                        collection, model, ..
2563                    }
2564                    | KvCommand::List {
2565                        collection, model, ..
2566                    }
2567                    | KvCommand::Delete {
2568                        collection, model, ..
2569                    } => (collection.as_str(), *model),
2570                    KvCommand::Rotate { collection, .. }
2571                    | KvCommand::History { collection, .. }
2572                    | KvCommand::Purge { collection, .. } => {
2573                        (collection.as_str(), CollectionModel::Vault)
2574                    }
2575                    KvCommand::InvalidateTags { collection, .. } => {
2576                        (collection.as_str(), CollectionModel::Kv)
2577                    }
2578                    KvCommand::Watch {
2579                        collection, model, ..
2580                    } => (collection.as_str(), *model),
2581                    KvCommand::Unseal { collection, .. } => {
2582                        (collection.as_str(), CollectionModel::Vault)
2583                    }
2584                };
2585                Some((collection, model))
2586            }
2587            QueryExpr::ConfigCommand(cmd) => {
2588                self.validate_config_command_before_auth(cmd)?;
2589                None
2590            }
2591            _ => None,
2592        };
2593
2594        let Some((name, expected_model)) = expected else {
2595            return Ok(());
2596        };
2597        let snapshot = self.inner.db.catalog_model_snapshot();
2598        let Some(actual_model) = snapshot
2599            .collections
2600            .iter()
2601            .find(|collection| collection.name == name)
2602            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2603        else {
2604            return Ok(());
2605        };
2606        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2607    }
2608
2609    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2610    /// `tq.table` matches a registered view name with the view's stored
2611    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2612    /// resolves correctly. Pure operation — no side effects.
2613    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2614        // Fast path: no views registered → return original expression.
2615        if self.inner.views.read().is_empty() {
2616            return expr;
2617        }
2618        self.rewrite_view_refs_inner(expr)
2619    }
2620
2621    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2622        use crate::storage::query::ast::{Filter, TableSource};
2623        match expr {
2624            QueryExpr::Table(mut tq) => {
2625                // 1. If the TableSource is a subquery, recurse into it so
2626                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2627                //    The legacy `table` field (set to a synthetic
2628                //    "__subq_NNNN" sentinel) stays as-is so callers that
2629                //    read it keep compiling.
2630                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2631                    tq.source = Some(TableSource::Subquery(Box::new(
2632                        self.rewrite_view_refs_inner(*body),
2633                    )));
2634                    return QueryExpr::Table(tq);
2635                }
2636
2637                // 2. Restore the source field (took it above for match).
2638                // When the source was `None` or `TableSource::Name(_)`, the
2639                // real lookup key is `tq.table` — check the view registry.
2640                let maybe_view = {
2641                    let views = self.inner.views.read();
2642                    views.get(&tq.table).cloned()
2643                };
2644                let Some(view) = maybe_view else {
2645                    return QueryExpr::Table(tq);
2646                };
2647
2648                // Issue #594 slice 9b — materialized views are read
2649                // from their backing collection, not by substituting
2650                // the body. Returning the TableQuery as-is lets the
2651                // normal table-read path resolve `SELECT FROM v`
2652                // against the collection provisioned at CREATE time.
2653                if view.materialized {
2654                    return QueryExpr::Table(tq);
2655                }
2656
2657                // Recurse into the view body — views may reference other
2658                // views. The recursion yields the final QueryExpr we need
2659                // to merge the outer's filter / limit / offset into.
2660                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2661
2662                // Phase 5: when the body is a Table we merge the outer
2663                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2664                // views filter recursively. Non-table bodies (Search,
2665                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2666                // with an outer Table query today — return the body
2667                // verbatim; outer predicates are lost. Full projection
2668                // merge lands in Phase 5.2.
2669                match inner_expr {
2670                    QueryExpr::Table(mut inner_tq) => {
2671                        if let Some(outer_filter) = tq.filter.take() {
2672                            inner_tq.filter = Some(match inner_tq.filter.take() {
2673                                Some(existing) => {
2674                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2675                                }
2676                                None => outer_filter,
2677                            });
2678                            // Keep the `Expr` form in lock-step with the
2679                            // merged `Filter`. The executor prefers
2680                            // `where_expr` and nulls `filter` when it is
2681                            // present (see `execute_query_inner`), so a
2682                            // stacked view whose outer predicate was only
2683                            // merged into `filter` would silently drop that
2684                            // predicate at eval time (#635).
2685                            inner_tq.where_expr = inner_tq
2686                                .filter
2687                                .as_ref()
2688                                .map(crate::storage::query::sql_lowering::filter_to_expr);
2689                        }
2690                        if let Some(outer_limit) = tq.limit {
2691                            inner_tq.limit = Some(match inner_tq.limit {
2692                                Some(existing) => existing.min(outer_limit),
2693                                None => outer_limit,
2694                            });
2695                        }
2696                        if let Some(outer_offset) = tq.offset {
2697                            inner_tq.offset = Some(match inner_tq.offset {
2698                                Some(existing) => existing + outer_offset,
2699                                None => outer_offset,
2700                            });
2701                        }
2702                        QueryExpr::Table(inner_tq)
2703                    }
2704                    other => other,
2705                }
2706            }
2707            QueryExpr::Join(mut jq) => {
2708                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2709                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2710                QueryExpr::Join(jq)
2711            }
2712            // Other variants don't carry nested QueryExpr that can reference
2713            // a view by table name. Return as-is.
2714            other => other,
2715        }
2716    }
2717
2718    fn resolve_table_expr_subqueries(
2719        &self,
2720        mut table: TableQuery,
2721        frame: &dyn super::statement_frame::ReadFrame,
2722    ) -> RedDBResult<TableQuery> {
2723        // Only a `Subquery` source needs recursive resolution. `.take()`
2724        // would otherwise drop a `Name` / `Function` source on the floor
2725        // (the `if let` skips the body but the take already cleared it),
2726        // which silently broke `SELECT * FROM components(g)` — the TVF
2727        // dispatch downstream keys off `TableSource::Function` and never
2728        // fired. Restore any non-subquery source unchanged (issue #795).
2729        match table.source.take() {
2730            Some(TableSource::Subquery(inner)) => {
2731                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2732                table.source = Some(TableSource::Subquery(Box::new(inner)));
2733            }
2734            other => table.source = other,
2735        }
2736
2737        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2738        for item in &mut table.select_items {
2739            if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
2740                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2741            }
2742        }
2743        if let Some(where_expr) = table.where_expr.take() {
2744            table.where_expr =
2745                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2746            table.filter = None;
2747        }
2748        if let Some(having_expr) = table.having_expr.take() {
2749            table.having_expr =
2750                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2751            table.having = None;
2752        }
2753        for expr in &mut table.group_by_exprs {
2754            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2755        }
2756        for clause in &mut table.order_by {
2757            if let Some(expr) = clause.expr.take() {
2758                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2759            }
2760        }
2761        Ok(table)
2762    }
2763
2764    fn resolve_select_expr_subqueries(
2765        &self,
2766        expr: QueryExpr,
2767        frame: &dyn super::statement_frame::ReadFrame,
2768    ) -> RedDBResult<QueryExpr> {
2769        match expr {
2770            QueryExpr::Table(table) => self
2771                .resolve_table_expr_subqueries(table, frame)
2772                .map(QueryExpr::Table),
2773            QueryExpr::Join(mut join) => {
2774                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2775                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2776                Ok(QueryExpr::Join(join))
2777            }
2778            other => Ok(other),
2779        }
2780    }
2781
2782    fn resolve_expr_subqueries(
2783        &self,
2784        expr: crate::storage::query::ast::Expr,
2785        outer_scopes: &[String],
2786        frame: &dyn super::statement_frame::ReadFrame,
2787    ) -> RedDBResult<crate::storage::query::ast::Expr> {
2788        use crate::storage::query::ast::Expr;
2789
2790        match expr {
2791            Expr::Subquery { query, span } => {
2792                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2793                if values.len() > 1 {
2794                    return Err(RedDBError::Query(
2795                        "scalar subquery returned more than one row".to_string(),
2796                    ));
2797                }
2798                Ok(Expr::Literal {
2799                    value: values.into_iter().next().unwrap_or(Value::Null),
2800                    span,
2801                })
2802            }
2803            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2804                op,
2805                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2806                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2807                span,
2808            }),
2809            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2810                op,
2811                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2812                span,
2813            }),
2814            Expr::Cast {
2815                inner,
2816                target,
2817                span,
2818            } => Ok(Expr::Cast {
2819                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2820                target,
2821                span,
2822            }),
2823            Expr::FunctionCall { name, args, span } => {
2824                let args = args
2825                    .into_iter()
2826                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2827                    .collect::<RedDBResult<Vec<_>>>()?;
2828                Ok(Expr::FunctionCall { name, args, span })
2829            }
2830            Expr::Case {
2831                branches,
2832                else_,
2833                span,
2834            } => {
2835                let branches = branches
2836                    .into_iter()
2837                    .map(|(cond, value)| {
2838                        Ok((
2839                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2840                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2841                        ))
2842                    })
2843                    .collect::<RedDBResult<Vec<_>>>()?;
2844                let else_ = else_
2845                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2846                    .transpose()?
2847                    .map(Box::new);
2848                Ok(Expr::Case {
2849                    branches,
2850                    else_,
2851                    span,
2852                })
2853            }
2854            Expr::IsNull {
2855                operand,
2856                negated,
2857                span,
2858            } => Ok(Expr::IsNull {
2859                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2860                negated,
2861                span,
2862            }),
2863            Expr::InList {
2864                target,
2865                values,
2866                negated,
2867                span,
2868            } => {
2869                let target =
2870                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2871                let mut resolved = Vec::new();
2872                for value in values {
2873                    if let Expr::Subquery { query, .. } = value {
2874                        resolved.extend(
2875                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2876                                .into_iter()
2877                                .map(Expr::lit),
2878                        );
2879                    } else {
2880                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2881                    }
2882                }
2883                Ok(Expr::InList {
2884                    target,
2885                    values: resolved,
2886                    negated,
2887                    span,
2888                })
2889            }
2890            Expr::Between {
2891                target,
2892                low,
2893                high,
2894                negated,
2895                span,
2896            } => Ok(Expr::Between {
2897                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2898                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2899                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2900                negated,
2901                span,
2902            }),
2903            other => Ok(other),
2904        }
2905    }
2906
2907    fn execute_expr_subquery_values(
2908        &self,
2909        subquery: crate::storage::query::ast::ExprSubquery,
2910        outer_scopes: &[String],
2911        frame: &dyn super::statement_frame::ReadFrame,
2912    ) -> RedDBResult<Vec<Value>> {
2913        let query = *subquery.query;
2914        if query_references_outer_scope(&query, outer_scopes) {
2915            return Err(RedDBError::Query(
2916                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2917            ));
2918        }
2919        let query = self.rewrite_view_refs(query);
2920        let query = self.resolve_select_expr_subqueries(query, frame)?;
2921        let query = self.authorize_relational_select_expr(query, frame)?;
2922        let result = match query {
2923            QueryExpr::Table(table) => {
2924                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2925            }
2926            QueryExpr::Join(join) => execute_runtime_join_query(&self.inner.db, &join)?,
2927            other => {
2928                return Err(RedDBError::Query(format!(
2929                    "expression subquery must be a SELECT query, got {}",
2930                    query_expr_name(&other)
2931                )))
2932            }
2933        };
2934        first_column_values(result)
2935    }
2936
2937    fn dispatch_expr(
2938        &self,
2939        expr: QueryExpr,
2940        query_str: &str,
2941        mode: QueryMode,
2942    ) -> RedDBResult<RuntimeQueryResult> {
2943        let statement = query_expr_name(&expr);
2944        match expr {
2945            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
2946                // Graph queries are not cacheable as prepared statements.
2947                Err(RedDBError::Query(
2948                    "graph queries cannot be used as prepared statements".to_string(),
2949                ))
2950            }
2951            QueryExpr::Table(table) => {
2952                let scope = self.ai_scope();
2953                let table = self.resolve_table_expr_subqueries(
2954                    table,
2955                    &scope as &dyn super::statement_frame::ReadFrame,
2956                )?;
2957                // Table-valued functions (e.g. components(g)) dispatch to a
2958                // read-only executor before any catalog/virtual-table routing
2959                // (issue #795).
2960                if let Some(TableSource::Function {
2961                    name,
2962                    args,
2963                    named_args,
2964                }) = table.source.clone()
2965                {
2966                    return Ok(RuntimeQueryResult {
2967                        query: query_str.to_string(),
2968                        mode,
2969                        statement,
2970                        engine: "runtime-graph-tvf",
2971                        result: self.execute_table_function(&name, &args, &named_args)?,
2972                        affected_rows: 0,
2973                        statement_type: "select",
2974                        bookmark: None,
2975                        notice: None,
2976                    });
2977                }
2978                // Inline-graph TVF (issue #799) on the prepared-statement /
2979                // direct-expr path. Result caching is wired on the
2980                // `execute_query_inner` path; here we just compute and return.
2981                if let Some(TableSource::InlineGraphFunction {
2982                    name,
2983                    nodes,
2984                    edges,
2985                    named_args,
2986                }) = table.source.clone()
2987                {
2988                    return Ok(RuntimeQueryResult {
2989                        query: query_str.to_string(),
2990                        mode,
2991                        statement,
2992                        engine: "runtime-graph-tvf-inline",
2993                        result: self.execute_inline_graph_function(
2994                            &name,
2995                            &nodes,
2996                            &edges,
2997                            &named_args,
2998                        )?,
2999                        affected_rows: 0,
3000                        statement_type: "select",
3001                        bookmark: None,
3002                        notice: None,
3003                    });
3004                }
3005                if super::red_schema::is_virtual_table(&table.table) {
3006                    return Ok(RuntimeQueryResult {
3007                        query: query_str.to_string(),
3008                        mode,
3009                        statement,
3010                        engine: "runtime-red-schema",
3011                        result: super::red_schema::red_query(
3012                            self,
3013                            &table.table,
3014                            &table,
3015                            &scope as &dyn super::statement_frame::ReadFrame,
3016                        )?,
3017                        affected_rows: 0,
3018                        statement_type: "select",
3019                        bookmark: None,
3020                        notice: None,
3021                    });
3022                }
3023                // `<graph>.<output>` analytics virtual view (issue #800).
3024                if let Some(view_result) = self.try_resolve_analytics_view(
3025                    &table,
3026                    &scope as &dyn super::statement_frame::ReadFrame,
3027                )? {
3028                    return Ok(RuntimeQueryResult {
3029                        query: query_str.to_string(),
3030                        mode,
3031                        statement,
3032                        engine: "runtime-graph-analytics-view",
3033                        result: view_result,
3034                        affected_rows: 0,
3035                        statement_type: "select",
3036                        bookmark: None,
3037                        notice: None,
3038                    });
3039                }
3040                let Some(table_with_rls) = self.authorize_relational_table_select(
3041                    table,
3042                    &scope as &dyn super::statement_frame::ReadFrame,
3043                )?
3044                else {
3045                    return Ok(RuntimeQueryResult {
3046                        query: query_str.to_string(),
3047                        mode,
3048                        statement,
3049                        engine: "runtime-table-rls",
3050                        result: crate::storage::query::unified::UnifiedResult::empty(),
3051                        affected_rows: 0,
3052                        statement_type: "select",
3053                        bookmark: None,
3054                        notice: None,
3055                    });
3056                };
3057                Ok(RuntimeQueryResult {
3058                    query: query_str.to_string(),
3059                    mode,
3060                    statement,
3061                    engine: "runtime-table",
3062                    result: execute_runtime_table_query(
3063                        &self.inner.db,
3064                        &table_with_rls,
3065                        Some(&self.inner.index_store),
3066                    )?,
3067                    affected_rows: 0,
3068                    statement_type: "select",
3069                    bookmark: None,
3070                    notice: None,
3071                })
3072            }
3073            QueryExpr::Join(join) => {
3074                let scope = self.ai_scope();
3075                let Some(join_with_rls) = self.authorize_relational_join_select(
3076                    join,
3077                    &scope as &dyn super::statement_frame::ReadFrame,
3078                )?
3079                else {
3080                    return Ok(RuntimeQueryResult {
3081                        query: query_str.to_string(),
3082                        mode,
3083                        statement,
3084                        engine: "runtime-join-rls",
3085                        result: crate::storage::query::unified::UnifiedResult::empty(),
3086                        affected_rows: 0,
3087                        statement_type: "select",
3088                        bookmark: None,
3089                        notice: None,
3090                    });
3091                };
3092                Ok(RuntimeQueryResult {
3093                    query: query_str.to_string(),
3094                    mode,
3095                    statement,
3096                    engine: "runtime-join",
3097                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
3098                    affected_rows: 0,
3099                    statement_type: "select",
3100                    bookmark: None,
3101                    notice: None,
3102                })
3103            }
3104            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3105                query: query_str.to_string(),
3106                mode,
3107                statement,
3108                engine: "runtime-vector",
3109                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
3110                affected_rows: 0,
3111                statement_type: "select",
3112                bookmark: None,
3113                notice: None,
3114            }),
3115            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3116                query: query_str.to_string(),
3117                mode,
3118                statement,
3119                engine: "runtime-hybrid",
3120                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
3121                affected_rows: 0,
3122                statement_type: "select",
3123                bookmark: None,
3124                notice: None,
3125            }),
3126            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3127                Err(RedDBError::Query(
3128                    super::red_schema::READ_ONLY_ERROR.to_string(),
3129                ))
3130            }
3131            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3132                Err(RedDBError::Query(
3133                    super::red_schema::READ_ONLY_ERROR.to_string(),
3134                ))
3135            }
3136            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3137                Err(RedDBError::Query(
3138                    super::red_schema::READ_ONLY_ERROR.to_string(),
3139                ))
3140            }
3141            QueryExpr::Insert(ref insert) => self
3142                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
3143                    self.execute_insert(query_str, insert)
3144                }),
3145            QueryExpr::Update(ref update) => self
3146                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3147                    self.execute_update(query_str, update)
3148                }),
3149            QueryExpr::Delete(ref delete) => self
3150                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3151                    self.execute_delete(query_str, delete)
3152                }),
3153            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3154            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3155            _ => Err(RedDBError::Query(format!(
3156                "prepared-statement execution does not support {statement} statements"
3157            ))),
3158        }
3159    }
3160
3161    /// Dispatch a graph-collection table-valued function call in FROM
3162    /// position (e.g. `SELECT * FROM components(g)`).
3163    ///
3164    /// Validates the function name and arity here, materializes the whole
3165    /// active graph read-only, then runs the algorithm via the shared
3166    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3167    fn execute_table_function(
3168        &self,
3169        name: &str,
3170        args: &[String],
3171        named_args: &[(String, f64)],
3172    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3173        if name.eq_ignore_ascii_case("red.diff") {
3174            return self.execute_vcs_diff_tvf(args, named_args);
3175        }
3176        if !is_graph_tvf_name(name) {
3177            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3178        }
3179        // Every graph-collection TVF takes exactly one graph argument.
3180        if args.len() != 1 {
3181            return Err(RedDBError::Query(format!(
3182                "table function '{name}' takes exactly 1 graph argument, got {}",
3183                args.len()
3184            )));
3185        }
3186
3187        // Read-only materialization of the full active graph. Passing `None`
3188        // for the projection uses the full graph store. Like #795/#796, the
3189        // v0 form runs over the whole graph store regardless of the collection
3190        // argument value. Materialization never mutates any store.
3191        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3192        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3193    }
3194
3195    pub(crate) fn revive_versioned_updates_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
3196        let mut guard = self.inner.pending_versioned_updates.write();
3197        let Some(pending) = guard.get_mut(&conn_id) else {
3198            return 0;
3199        };
3200
3201        let store = self.inner.db.store();
3202        let mut reverted = 0usize;
3203        pending.retain(|(collection, old_id, new_id, xid, previous_xmax)| {
3204            if *xid < stamper_xid {
3205                return true;
3206            }
3207            if let Some(manager) = store.get_collection(collection) {
3208                if let Some(mut old) = manager.get(*old_id) {
3209                    if old.xmax == *xid {
3210                        old.set_xmax(*previous_xmax);
3211                        let _ = manager.update(old);
3212                    }
3213                }
3214            }
3215            let _ = store.delete_batch(collection, &[*new_id]);
3216            reverted += 1;
3217            false
3218        });
3219        if pending.is_empty() {
3220            guard.remove(&conn_id);
3221        }
3222        reverted
3223    }
3224
3225    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3226    /// `RuntimeQueryResult` so callers over the SQL interface see the
3227    /// plan tree in the same shape a SELECT produces.
3228    ///
3229    /// Columns: `op`, `source`, `estimated_rows`, `estimated_cost`, `depth`.
3230    /// Nodes are walked depth-first; `depth` counts from 0 at the
3231    /// root so a text renderer can indent without re-walking.
3232    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3233        let explain = self.explain_query(inner_sql)?;
3234
3235        let columns = vec![
3236            "op".to_string(),
3237            "source".to_string(),
3238            "estimated_rows".to_string(),
3239            "estimated_cost".to_string(),
3240            "depth".to_string(),
3241        ];
3242
3243        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3244
3245        // Prepend `CteScan` markers when the query carried a leading
3246        // WITH clause. The CTE bodies are already inlined into the
3247        // main plan tree, but operators reading EXPLAIN need to see
3248        // which named CTEs were resolved — without this row the plan
3249        // would look indistinguishable from a hand-inlined query.
3250        for name in &explain.cte_materializations {
3251            use std::sync::Arc;
3252            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3253            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3254            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3255            rec.set_arc(Arc::from("estimated_rows"), Value::Float(0.0));
3256            rec.set_arc(Arc::from("estimated_cost"), Value::Float(0.0));
3257            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3258            records.push(rec);
3259        }
3260
3261        walk_plan_node(&explain.logical_plan.root, 0, &mut records);
3262
3263        let result = crate::storage::query::unified::UnifiedResult {
3264            columns,
3265            records,
3266            stats: Default::default(),
3267            pre_serialized_json: None,
3268        };
3269
3270        Ok(RuntimeQueryResult {
3271            query: raw_query.to_string(),
3272            mode: explain.mode,
3273            statement: "explain",
3274            engine: "runtime-explain",
3275            result,
3276            affected_rows: 0,
3277            statement_type: "select",
3278            bookmark: None,
3279            notice: None,
3280        })
3281    }
3282
3283    fn explain_analyze_as_rows(
3284        &self,
3285        raw_query: &str,
3286        inner_sql: &str,
3287    ) -> RedDBResult<RuntimeQueryResult> {
3288        if !starts_with_dml_keyword(inner_sql) {
3289            return Err(RedDBError::Query(
3290                "EXPLAIN ANALYZE currently supports INSERT, UPDATE, and DELETE".to_string(),
3291            ));
3292        }
3293
3294        let explain = self.explain_query(inner_sql)?;
3295        let conn_id = current_connection_id();
3296        if self.inner.tx_contexts.read().contains_key(&conn_id) {
3297            return Err(RedDBError::Query(
3298                "EXPLAIN ANALYZE requires no active transaction".to_string(),
3299            ));
3300        }
3301
3302        self.execute_query_inner("BEGIN")?;
3303        let started = std::time::Instant::now();
3304        let execution = self.execute_query_inner(inner_sql);
3305        let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
3306        let rollback = self.execute_query_inner("ROLLBACK");
3307
3308        let affected_rows = match execution {
3309            Ok(result) => result.affected_rows,
3310            Err(err) => {
3311                rollback?;
3312                return Err(err);
3313            }
3314        };
3315        rollback?;
3316
3317        let columns = vec![
3318            "op".to_string(),
3319            "source".to_string(),
3320            "estimated_rows".to_string(),
3321            "estimated_cost".to_string(),
3322            "actual_rows".to_string(),
3323            "actual_ms".to_string(),
3324            "depth".to_string(),
3325        ];
3326        let mut records = Vec::new();
3327        walk_analyze_plan_node(
3328            &explain.logical_plan.root,
3329            0,
3330            affected_rows,
3331            elapsed_ms,
3332            &mut records,
3333        );
3334
3335        let result = crate::storage::query::unified::UnifiedResult {
3336            columns,
3337            records,
3338            stats: Default::default(),
3339            pre_serialized_json: None,
3340        };
3341
3342        Ok(RuntimeQueryResult {
3343            query: raw_query.to_string(),
3344            mode: explain.mode,
3345            statement: "explain_analyze",
3346            engine: "runtime-explain-analyze",
3347            result,
3348            affected_rows: 0,
3349            statement_type: "select",
3350            bookmark: None,
3351            notice: None,
3352        })
3353    }
3354}
3355
3356fn strip_explain_analyze_prefix(sql: &str) -> Option<&str> {
3357    let rest = strip_keyword_ci(sql.trim_start(), "EXPLAIN")?.trim_start();
3358    Some(strip_keyword_ci(rest, "ANALYZE")?.trim_start()).filter(|inner| !inner.is_empty())
3359}
3360
3361fn strip_keyword_ci<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
3362    if sql.len() < keyword.len() {
3363        return None;
3364    }
3365    let (head, rest) = sql.split_at(keyword.len());
3366    if !head.eq_ignore_ascii_case(keyword) {
3367        return None;
3368    }
3369    if rest
3370        .chars()
3371        .next()
3372        .is_some_and(|ch| !ch.is_ascii_whitespace())
3373    {
3374        return None;
3375    }
3376    Some(rest)
3377}
3378
3379fn starts_with_dml_keyword(sql: &str) -> bool {
3380    let trimmed = sql.trim_start();
3381    let head_end = trimmed
3382        .find(|ch: char| ch.is_ascii_whitespace())
3383        .unwrap_or(trimmed.len());
3384    let head = &trimmed[..head_end];
3385    head.eq_ignore_ascii_case("INSERT")
3386        || head.eq_ignore_ascii_case("UPDATE")
3387        || head.eq_ignore_ascii_case("DELETE")
3388}
3389
3390fn walk_plan_node(
3391    node: &crate::storage::query::planner::CanonicalLogicalNode,
3392    depth: usize,
3393    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3394) {
3395    use std::sync::Arc;
3396    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3397    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3398    rec.set_arc(
3399        Arc::from("source"),
3400        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3401    );
3402    rec.set_arc(
3403        Arc::from("estimated_rows"),
3404        Value::Float(node.estimated_rows),
3405    );
3406    rec.set_arc(
3407        Arc::from("estimated_cost"),
3408        Value::Float(node.operator_cost),
3409    );
3410    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3411    out.push(rec);
3412    for child in &node.children {
3413        walk_plan_node(child, depth + 1, out);
3414    }
3415}
3416
3417fn walk_analyze_plan_node(
3418    node: &crate::storage::query::planner::CanonicalLogicalNode,
3419    depth: usize,
3420    affected_rows: u64,
3421    elapsed_ms: f64,
3422    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3423) {
3424    use std::sync::Arc;
3425    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3426    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3427    rec.set_arc(
3428        Arc::from("source"),
3429        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3430    );
3431    rec.set_arc(
3432        Arc::from("estimated_rows"),
3433        Value::Float(node.estimated_rows),
3434    );
3435    rec.set_arc(
3436        Arc::from("estimated_cost"),
3437        Value::Float(node.operator_cost),
3438    );
3439    rec.set_arc(
3440        Arc::from("actual_rows"),
3441        Value::UnsignedInteger(if depth == 0 { affected_rows } else { 0 }),
3442    );
3443    rec.set_arc(
3444        Arc::from("actual_ms"),
3445        Value::Float(if depth == 0 { elapsed_ms } else { 0.0 }),
3446    );
3447    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3448    out.push(rec);
3449
3450    for child in &node.children {
3451        walk_analyze_plan_node(child, depth + 1, 0, 0.0, out);
3452    }
3453}
3454
3455#[cfg(test)]
3456mod inline_graph_tvf_tests {
3457    use super::*;
3458
3459    fn scopes_for(sql: &str) -> HashSet<String> {
3460        let expr = crate::storage::query::parser::parse(sql)
3461            .expect("parse")
3462            .query;
3463        query_expr_result_cache_scopes(&expr)
3464    }
3465
3466    #[test]
3467    fn inline_tvf_cache_scopes_include_source_collections() {
3468        // The result-cache key for the inline form must derive from the
3469        // `nodes`/`edges` source collections so a write to either invalidates
3470        // the cached result (issue #799).
3471        let scopes = scopes_for(
3472            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3473        );
3474        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3475        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3476    }
3477
3478    #[test]
3479    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3480        // The graph-collection form still materializes the active graph, but
3481        // result-cache invalidation is scoped to the named graph argument so
3482        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3483        let scopes = scopes_for("SELECT * FROM components(g)");
3484        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3485    }
3486
3487    #[test]
3488    fn abstract_degree_centrality_counts_undirected_endpoints() {
3489        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3490        let edges = vec![
3491            ("a".to_string(), "b".to_string(), 1.0_f32),
3492            ("b".to_string(), "c".to_string(), 1.0_f32),
3493        ];
3494        let degrees = abstract_degree_centrality(&nodes, &edges);
3495        assert_eq!(
3496            degrees,
3497            vec![
3498                ("a".to_string(), 1),
3499                ("b".to_string(), 2),
3500                ("c".to_string(), 1),
3501            ]
3502        );
3503    }
3504}