Skip to main content

reddb_server/runtime/
impl_ddl.rs

1//! DDL execution: CREATE TABLE, DROP TABLE, ALTER TABLE via SQL AST
2//!
3//! Translates DDL statements into collection-level operations on the
4//! underlying `UnifiedStore`.  RedDB uses a flexible schema-on-read
5//! model, so column definitions are advisory metadata rather than
6//! rigid constraints.
7
8use super::*;
9use crate::catalog::CollectionModel;
10use crate::runtime::audit_log::{AuditAuthSource, AuditEvent, AuditFieldEscaper, Outcome};
11use crate::runtime::ddl::polymorphic_resolver;
12use reddb_rql::analyzer::{analyze_create_table, resolve_declared_data_type};
13use reddb_rql::ast::CreateColumnDef;
14use reddb_rql::ast::{
15    CreateVcsRefQuery, DropForkQuery, DropVcsRefQuery, ForkStoreQuery, VcsRefKind,
16};
17use std::collections::{BTreeSet, HashMap, HashSet};
18
19fn operational_wal_retention_floor(durable_lsn: u64) -> u64 {
20    if durable_lsn == 0 {
21        0
22    } else {
23        1
24    }
25}
26
27fn validate_store_fork_lsn(
28    fork_lsn: u64,
29    retention_floor: u64,
30    durable_lsn: u64,
31) -> RedDBResult<()> {
32    if fork_lsn < retention_floor {
33        return Err(RedDBError::Query(format!(
34            "cannot fork store at LSN {fork_lsn}: below operational WAL retention floor \
35             {retention_floor}; restore from backup for older recovery points"
36        )));
37    }
38    if fork_lsn > durable_lsn {
39        return Err(RedDBError::Query(format!(
40            "cannot fork store at LSN {fork_lsn}: current durable LSN is {durable_lsn}; \
41             restore from backup for recovery points outside the retained WAL window"
42        )));
43    }
44    Ok(())
45}
46
47fn vault_master_key_ref(collection: &str) -> String {
48    format!("red.vault.{collection}.master_key")
49}
50
51impl RedDBRuntime {
52    /// Execute CREATE TABLE
53    ///
54    /// Creates a new collection in the store.  Column definitions are
55    /// recorded for introspection but do not enforce rigid schema
56    /// constraints.
57    pub fn execute_create_table(
58        &self,
59        raw_query: &str,
60        query: &CreateTableQuery,
61    ) -> RedDBResult<RuntimeQueryResult> {
62        if query.collection_model != CollectionModel::Table {
63            if query
64                .columns
65                .iter()
66                .any(|column| column.generated.is_some() || column.check.is_some())
67            {
68                return Err(RedDBError::Query(
69                    "schema expressions currently require TABLE collections".to_string(),
70                ));
71            }
72            return self.execute_create_keyed_collection(raw_query, query);
73        }
74        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
75        let store = self.inner.db.store();
76        analyze_create_table(query).map_err(|err| RedDBError::Query(err.to_string()))?;
77        // Reject an AI policy that wires a provider/model to a modality it
78        // cannot serve — fail at DDL time, before the collection exists,
79        // using the capability matrix from #1269.
80        if let Some(policy) = &query.ai_policy {
81            validate_ai_policy_modalities(policy)?;
82        }
83        crate::reserved_fields::ensure_no_reserved_public_item_fields(
84            query.columns.iter().map(|column| column.name.as_str()),
85            &format!("table '{}'", query.name),
86        )?;
87        // Check if the collection already exists.
88        let exists = store.get_collection(&query.name).is_some();
89        if exists {
90            if query.if_not_exists {
91                return Ok(RuntimeQueryResult::ok_message(
92                    raw_query.to_string(),
93                    &format!("table '{}' already exists", query.name),
94                    "create",
95                ));
96            }
97            return Err(RedDBError::Query(format!(
98                "table '{}' already exists",
99                query.name
100            )));
101        }
102
103        // Build and validate the contract before mutating storage so invalid
104        // SQL types / duplicate columns do not leave partial side effects.
105        let contract = collection_contract_from_create_table(query)?;
106        crate::application::collection_contract_enforcer::validate_contract_expressions(&contract)?;
107        validate_event_subscriptions(self, &query.name, &contract.subscriptions)?;
108        // Create the collection.
109        store
110            .create_collection(&query.name)
111            .map_err(|err| RedDBError::Internal(err.to_string()))?;
112        for subscription in &contract.subscriptions {
113            ensure_event_target_queue(self, &subscription.target_queue)?;
114        }
115        if let Some(default_ttl_ms) = query.default_ttl_ms {
116            self.inner
117                .db
118                .set_collection_default_ttl_ms(&query.name, default_ttl_ms);
119        }
120        self.inner
121            .db
122            .save_collection_contract(contract)
123            .map_err(|err| RedDBError::Internal(err.to_string()))?;
124        if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
125            store.set_config_tree(
126                &format!("red.collection_tenants.{}", query.name),
127                &crate::serde_json::Value::String(tenant_id),
128            );
129        }
130        self.inner
131            .db
132            .persist_metadata()
133            .map_err(|err| RedDBError::Internal(err.to_string()))?;
134        self.refresh_table_planner_stats(&query.name);
135        self.invalidate_result_cache();
136        // Issue #120 — feed the create into the schema-vocabulary
137        // reverse index so AskPipeline (#121) sees this collection.
138        let columns: Vec<String> = query.columns.iter().map(|col| col.name.clone()).collect();
139        self.schema_vocabulary_apply(
140            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
141                collection: query.name.clone(),
142                columns,
143                type_tags: Vec::new(),
144                description: None,
145            },
146        );
147        // Partition metadata (Phase 2.2 PG parity).
148        //
149        // When the CREATE TABLE carries a `PARTITION BY RANGE|LIST|HASH (col)`
150        // clause, stamp the partition config into `red_config` under
151        // `partition.{table}.{by,column}`. Children are registered separately
152        // via `ALTER TABLE parent ATTACH PARTITION child ...`.
153        if let Some(spec) = &query.partition_by {
154            let kind_str = match spec.kind {
155                reddb_rql::ast::PartitionKind::Range => "range",
156                reddb_rql::ast::PartitionKind::List => "list",
157                reddb_rql::ast::PartitionKind::Hash => "hash",
158            };
159            store.set_config_tree(
160                &format!("partition.{}.by", query.name),
161                &crate::serde_json::Value::String(kind_str.to_string()),
162            );
163            store.set_config_tree(
164                &format!("partition.{}.column", query.name),
165                &crate::serde_json::Value::String(spec.column.clone()),
166            );
167        }
168
169        // Table-scoped multi-tenancy (Phase 2.5.4).
170        //
171        // `CREATE TABLE t (...) TENANT BY (col)` declaration:
172        //   1. Persists the `tenant_tables.{table}.column` marker so
173        //      INSERTs can auto-fill and future opens re-hydrate.
174        //   2. Registers the table in the in-memory `tenant_tables`
175        //      HashMap used by the DML auto-fill path.
176        //   3. Installs an implicit RLS policy equivalent to
177        //      `USING (col = CURRENT_TENANT())` across all actions.
178        //   4. Flips `rls_enabled_tables` on so the policy applies.
179        if let Some(col) = &query.tenant_by {
180            store.set_config_tree(
181                &format!("tenant_tables.{}.column", query.name),
182                &crate::serde_json::Value::String(col.clone()),
183            );
184            self.register_tenant_table(&query.name, col);
185        }
186
187        let ttl_suffix = query
188            .default_ttl_ms
189            .map(|ttl_ms| format!(" with default TTL {}ms", ttl_ms))
190            .unwrap_or_default();
191
192        let tenant_suffix = query
193            .tenant_by
194            .as_ref()
195            .map(|col| format!(" (tenant-scoped by {col})"))
196            .unwrap_or_default();
197
198        Ok(RuntimeQueryResult::ok_message(
199            raw_query.to_string(),
200            &format!(
201                "table '{}' created{}{}",
202                query.name, ttl_suffix, tenant_suffix
203            ),
204            "create",
205        ))
206    }
207
208    fn execute_create_keyed_collection(
209        &self,
210        raw_query: &str,
211        query: &CreateTableQuery,
212    ) -> RedDBResult<RuntimeQueryResult> {
213        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
214        if is_system_schema_name(&query.name) {
215            return Err(RedDBError::Query("system schema is read-only".to_string()));
216        }
217        let store = self.inner.db.store();
218        let label = polymorphic_resolver::model_name(query.collection_model);
219        if store.get_collection(&query.name).is_some() {
220            if query.if_not_exists {
221                return Ok(RuntimeQueryResult::ok_message(
222                    raw_query.to_string(),
223                    &format!("{label} '{}' already exists", query.name),
224                    "create",
225                ));
226            }
227            return Err(RedDBError::Query(format!(
228                "{label} '{}' already exists",
229                query.name
230            )));
231        }
232
233        store
234            .create_collection(&query.name)
235            .map_err(|err| RedDBError::Internal(err.to_string()))?;
236        if query.collection_model == CollectionModel::Vault {
237            self.provision_vault_key_material(&query.name, query.vault_own_master_key)?;
238            let key_scope = if query.vault_own_master_key {
239                "own"
240            } else {
241                "cluster"
242            };
243            store.set_config_tree(
244                &format!("red.vault.{}.key_scope", query.name),
245                &crate::serde_json::Value::String(key_scope.to_string()),
246            );
247            store.set_config_tree(
248                &format!("red.vault.{}.status", query.name),
249                &crate::serde_json::Value::String("sealed".to_string()),
250            );
251        }
252        if query.collection_model == CollectionModel::Metrics {
253            for spec in &query.metrics_rollup_policies {
254                let policy = crate::storage::timeseries::retention::DownsamplePolicy::parse(spec)
255                    .ok_or_else(|| {
256                    RedDBError::Query(format!("invalid metrics rollup policy '{}'", spec))
257                })?;
258                if policy.source != "raw" {
259                    return Err(RedDBError::Query(format!(
260                        "invalid metrics rollup policy '{}': metrics v0 rollups must use raw as source",
261                        spec
262                    )));
263                }
264                if !matches!(
265                    policy.aggregation.as_str(),
266                    "avg" | "sum" | "min" | "max" | "count"
267                ) {
268                    return Err(RedDBError::Query(format!(
269                        "invalid metrics rollup policy '{}': supported aggregations are avg, sum, min, max, count",
270                        spec
271                    )));
272                }
273            }
274            if let Some(raw_retention_ms) = query.default_ttl_ms {
275                self.inner
276                    .db
277                    .set_collection_default_ttl_ms(&query.name, raw_retention_ms);
278                store.set_config_tree(
279                    &format!("red.metrics.{}.raw_retention_ms", query.name),
280                    &crate::serde_json::Value::Number(raw_retention_ms as f64),
281                );
282            }
283            let tenant_identity = query
284                .tenant_by
285                .clone()
286                .unwrap_or_else(|| "current_tenant".to_string());
287            store.set_config_tree(
288                &format!("red.metrics.{}.tenant_identity", query.name),
289                &crate::serde_json::Value::String(tenant_identity),
290            );
291            store.set_config_tree(
292                &format!("red.metrics.{}.namespace", query.name),
293                &crate::serde_json::Value::String("default".to_string()),
294            );
295            if !query.metrics_rollup_policies.is_empty() {
296                store.set_config_tree(
297                    &format!("red.metrics.{}.rollup_policies", query.name),
298                    &crate::serde_json::Value::Array(
299                        query
300                            .metrics_rollup_policies
301                            .iter()
302                            .cloned()
303                            .map(crate::serde_json::Value::String)
304                            .collect(),
305                    ),
306                );
307            }
308        }
309        let contract = if query.collection_model == CollectionModel::Metrics {
310            metrics_collection_contract(query)
311        } else {
312            keyed_collection_contract(
313                &query.name,
314                query.collection_model,
315                query.analytics_config.clone(),
316            )
317        };
318        self.inner
319            .db
320            .save_collection_contract(contract)
321            .map_err(|err| RedDBError::Internal(err.to_string()))?;
322        if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
323            store.set_config_tree(
324                &format!("red.collection_tenants.{}", query.name),
325                &crate::serde_json::Value::String(tenant_id),
326            );
327        }
328        self.inner
329            .db
330            .persist_metadata()
331            .map_err(|err| RedDBError::Internal(err.to_string()))?;
332        self.invalidate_result_cache();
333
334        Ok(RuntimeQueryResult::ok_message(
335            raw_query.to_string(),
336            &format!("{label} '{}' created", query.name),
337            "create",
338        ))
339    }
340
341    /// Bootstrap an internal graph collection declared `WITH ANALYTICS`
342    /// (issue #803). The SQL DDL path refuses `red.*` names via
343    /// [`is_system_schema_name`], so built-ins like `red.topology.cluster`
344    /// cannot be created through `CREATE GRAPH`; this method drives the same
345    /// contract-build / save / persist path directly, bypassing only that
346    /// guard. Idempotent: a no-op when the collection already exists (its
347    /// analytics contract is recovered from the WAL on restart), so it is safe
348    /// to call unconditionally on every boot.
349    pub fn ensure_system_graph_with_analytics(
350        &self,
351        name: &str,
352        outputs: &[crate::catalog::AnalyticsOutput],
353    ) -> RedDBResult<()> {
354        let store = self.inner.db.store();
355        if store.get_collection(name).is_some() {
356            return Ok(());
357        }
358        let analytics_config = outputs
359            .iter()
360            .map(|output| crate::catalog::AnalyticsViewDescriptor {
361                output: *output,
362                algorithm: None,
363                resolution: None,
364                max_iterations: None,
365                tolerance: None,
366            })
367            .collect();
368        store
369            .create_collection(name)
370            .map_err(|err| RedDBError::Internal(err.to_string()))?;
371        let contract = keyed_collection_contract(
372            name,
373            crate::catalog::CollectionModel::Graph,
374            analytics_config,
375        );
376        self.inner
377            .db
378            .save_collection_contract(contract)
379            .map_err(|err| RedDBError::Internal(err.to_string()))?;
380        self.inner
381            .db
382            .persist_metadata()
383            .map_err(|err| RedDBError::Internal(err.to_string()))?;
384        self.invalidate_result_cache_process_only();
385        Ok(())
386    }
387
388    pub fn execute_create_collection(
389        &self,
390        raw_query: &str,
391        query: &CreateCollectionQuery,
392    ) -> RedDBResult<RuntimeQueryResult> {
393        let model = match query.kind.as_str() {
394            "graph" => CollectionModel::Graph,
395            "document" => CollectionModel::Document,
396            "metrics" => CollectionModel::Metrics,
397            "vector.turbo" => {
398                let dimension = query.vector_dimension.ok_or_else(|| {
399                    RedDBError::Query(
400                        "CREATE COLLECTION KIND vector.turbo requires DIM".to_string(),
401                    )
402                })?;
403                let create = CreateVectorQuery {
404                    name: query.name.clone(),
405                    dimension,
406                    metric: query
407                        .vector_metric
408                        .unwrap_or(crate::storage::engine::distance::DistanceMetric::Cosine),
409                    if_not_exists: query.if_not_exists,
410                };
411                let result = self.execute_create_vector(raw_query, &create)?;
412                // Issue #693 — durable kind marker that distinguishes
413                // this collection from the legacy `vector` path on
414                // every restart. Runtime state (TurboQuantIndex +
415                // TurboExtent) is lazily materialised by
416                // `RedDB::turbo_state` on first INSERT/SEARCH so the
417                // marker is the only thing that needs to survive.
418                let store = self.inner.db.store();
419                crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
420                self.inner
421                    .db
422                    .persist_metadata()
423                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
424                // Eagerly construct the state so the TurboExtent (if
425                // any) is reserved at creation time rather than on
426                // first INSERT. Errors are swallowed: the lazy path in
427                // `turbo_state` will retry on the next access.
428                let _ = self.inner.db.turbo_state(&query.name);
429                return Ok(result);
430            }
431            // KIND blockchain — issue #523 foundation: stored on top of a
432            // Table-shaped collection. The `chain` marker + reserved-column
433            // discipline make the difference. Schema validation, conflict
434            // retries, and `verify_chain` come in later iterations.
435            "blockchain" => CollectionModel::Table,
436            other => {
437                return Err(RedDBError::Query(format!(
438                    "NOT_YET_SUPPORTED: CREATE COLLECTION KIND {other} is not implemented"
439                )));
440            }
441        };
442        let create = CreateTableQuery {
443            collection_model: model,
444            name: query.name.clone(),
445            columns: Vec::new(),
446            unique_constraints: Vec::new(),
447            if_not_exists: query.if_not_exists,
448            default_ttl_ms: None,
449            metrics_rollup_policies: Vec::new(),
450            context_index_fields: Vec::new(),
451            context_index_enabled: false,
452            timestamps: false,
453            partition_by: None,
454            tenant_by: None,
455            append_only: false,
456            subscriptions: Vec::new(),
457            analytics_config: Vec::new(),
458            vault_own_master_key: false,
459            ai_policy: None,
460        };
461        let result = self.execute_create_table(raw_query, &create)?;
462        if query.kind == "blockchain" {
463            self.install_blockchain_kind(&query.name)?;
464        }
465        // Issue #522 — wire `SIGNED_BY (...)` into the runtime. The parser
466        // already produces a validated 32-byte pubkey list; installing
467        // the registry stamps the per-collection signer set into
468        // `red_config` so the INSERT path can load it cheaply.
469        if !query.allowed_signers.is_empty() {
470            let actor = crate::runtime::impl_core::current_user_projected()
471                .unwrap_or_else(|| "@system/create-collection".to_string());
472            crate::runtime::signed_writes_kind::install(
473                &self.inner.db.store(),
474                &query.name,
475                &query.allowed_signers,
476                &actor,
477            );
478        }
479        Ok(result)
480    }
481
482    /// Stamp `red.collection.{name}.kind = "chain"` and append the genesis
483    /// row. Idempotent against `IF NOT EXISTS`: if the collection already
484    /// has a row at height 0 we leave it.
485    fn install_blockchain_kind(&self, name: &str) -> RedDBResult<()> {
486        use crate::runtime::blockchain_kind;
487        use crate::storage::unified::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
488        use std::sync::Arc;
489
490        let store = self.inner.db.store();
491        blockchain_kind::mark_as_chain(&store, name);
492
493        let existing_tip = blockchain_kind::chain_tip(&store, name);
494        if existing_tip.height.is_some() {
495            return Ok(());
496        }
497
498        let fields = blockchain_kind::genesis_fields(blockchain_kind::now_ms());
499        let named: std::collections::HashMap<String, reddb_types::Value> =
500            fields.into_iter().collect();
501        let entity = UnifiedEntity::new(
502            EntityId::new(0),
503            EntityKind::TableRow {
504                table: Arc::from(name),
505                row_id: 0,
506            },
507            EntityData::Row(RowData {
508                columns: Vec::new(),
509                named: Some(named),
510                schema: None,
511            }),
512        );
513        store
514            .insert_auto(name, entity)
515            .map_err(|err| RedDBError::Internal(err.to_string()))?;
516        // #524: prime the in-memory tip cache so the chain-tip endpoint and
517        // subsequent INSERTs don't have to scan the collection to find genesis.
518        if let Some(tip) = blockchain_kind::chain_tip_full(&store, name) {
519            self.inner
520                .chain_tip_cache
521                .lock()
522                .insert(name.to_string(), tip);
523        }
524        Ok(())
525    }
526
527    pub fn execute_create_vector(
528        &self,
529        raw_query: &str,
530        query: &CreateVectorQuery,
531    ) -> RedDBResult<RuntimeQueryResult> {
532        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
533        if is_system_schema_name(&query.name) {
534            return Err(RedDBError::Query("system schema is read-only".to_string()));
535        }
536        let store = self.inner.db.store();
537        if store.get_collection(&query.name).is_some() {
538            if query.if_not_exists {
539                return Ok(RuntimeQueryResult::ok_message(
540                    raw_query.to_string(),
541                    &format!("vector '{}' already exists", query.name),
542                    "create",
543                ));
544            }
545            return Err(RedDBError::Query(format!(
546                "vector '{}' already exists",
547                query.name
548            )));
549        }
550
551        store
552            .create_collection(&query.name)
553            .map_err(|err| RedDBError::Internal(err.to_string()))?;
554        self.inner
555            .db
556            .save_collection_contract(vector_collection_contract(query))
557            .map_err(|err| RedDBError::Internal(err.to_string()))?;
558        if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
559            store.set_config_tree(
560                &format!("red.collection_tenants.{}", query.name),
561                &crate::serde_json::Value::String(tenant_id),
562            );
563        }
564        // Slice G (#675) — register `vector.turbo` as the built-in
565        // baseline for every newly-created vector collection. The
566        // marker is the durable signal `RedDB::turbo_state` reads to
567        // materialise the TurboQuantIndex + TurboExtent on first
568        // INSERT/SEARCH. Pre-existing vector collections were created
569        // without the marker and stay on the brute-force fallback path
570        // until they are explicitly migrated — there is no implicit
571        // backfill in this slice.
572        crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
573        self.inner
574            .db
575            .persist_metadata()
576            .map_err(|err| RedDBError::Internal(err.to_string()))?;
577        // Eagerly materialise the per-collection turbo state so the
578        // TurboExtent (when the store is paged) is reserved at CREATE
579        // time rather than on the first INSERT. Failures are swallowed
580        // — the lazy path in `turbo_state` retries on next access.
581        let _ = self.inner.db.turbo_state(&query.name);
582        self.invalidate_result_cache();
583
584        Ok(RuntimeQueryResult::ok_message(
585            raw_query.to_string(),
586            &format!("vector '{}' created", query.name),
587            "create",
588        ))
589    }
590
591    fn provision_vault_key_material(
592        &self,
593        collection: &str,
594        own_master_key: bool,
595    ) -> RedDBResult<()> {
596        let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
597            RedDBError::Query("CREATE VAULT requires an enabled, unsealed vault".to_string())
598        })?;
599        if !auth_store.is_vault_backed() {
600            return Err(RedDBError::Query(
601                "CREATE VAULT requires an enabled, unsealed vault".to_string(),
602            ));
603        }
604
605        if auth_store.vault_secret_key().is_none() {
606            let key = crate::auth::store::random_bytes(32);
607            auth_store
608                .vault_kv_try_set("red.secret.aes_key".to_string(), hex::encode(key))
609                .map_err(|err| RedDBError::Query(err.to_string()))?;
610        }
611
612        if own_master_key {
613            let key = crate::auth::store::random_bytes(32);
614            auth_store
615                .vault_kv_try_set(vault_master_key_ref(collection), hex::encode(key))
616                .map_err(|err| RedDBError::Query(err.to_string()))?;
617        }
618
619        Ok(())
620    }
621
622    /// Execute DROP TABLE
623    ///
624    /// Drops the collection and all its data from the store.
625    pub fn execute_drop_table(
626        &self,
627        raw_query: &str,
628        query: &DropTableQuery,
629    ) -> RedDBResult<RuntimeQueryResult> {
630        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
631        let store = self.inner.db.store();
632
633        if is_system_schema_name(&query.name) {
634            return Err(RedDBError::Query("system schema is read-only".to_string()));
635        }
636
637        let exists = store.get_collection(&query.name).is_some();
638        if !exists {
639            if query.if_exists {
640                return Ok(RuntimeQueryResult::ok_message(
641                    raw_query.to_string(),
642                    &format!("table '{}' does not exist", query.name),
643                    "drop",
644                ));
645            }
646            return Err(RedDBError::NotFound(format!(
647                "table '{}' not found",
648                query.name
649            )));
650        }
651        let actual =
652            polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
653        polymorphic_resolver::ensure_model_match(CollectionModel::Table, actual)?;
654
655        // Emit 1 collection_dropped event before storage is wiped.
656        // Queue is preserved; subscription is removed with the contract below.
657        let final_count = store
658            .get_collection(&query.name)
659            .map(|manager| manager.query_all(|_| true).len() as u64)
660            .unwrap_or(0);
661        crate::runtime::mutation::emit_collection_dropped_event_for_collection(
662            self,
663            &query.name,
664            final_count,
665        )?;
666
667        let topology_lock = self.inner.index_store.collection_topology_lock(&query.name);
668        let _topology_guard = topology_lock.write();
669        let orphaned_indices: Vec<String> = self
670            .inner
671            .index_store
672            .list_indices(&query.name)
673            .into_iter()
674            .map(|index| index.name)
675            .collect();
676        for name in &orphaned_indices {
677            self.inner.index_store.drop_index(name, &query.name);
678        }
679
680        store
681            .drop_collection(&query.name)
682            .map_err(|err| RedDBError::Internal(err.to_string()))?;
683        self.inner.db.invalidate_vector_index(&query.name);
684        self.inner.db.clear_collection_default_ttl_ms(&query.name);
685        self.inner
686            .db
687            .remove_collection_contract(&query.name)
688            .map_err(|err| RedDBError::Internal(err.to_string()))?;
689        self.clear_table_planner_stats(&query.name);
690        self.invalidate_result_cache();
691        // Issue #119: a dropped collection vanishes from every
692        // (tenant, role)'s visible-collections set. Auth is optional in
693        // embedded mode so guard the call.
694        if let Some(store) = self.inner.auth_store.read().clone() {
695            store.invalidate_visible_collections_cache();
696        }
697        self.inner
698            .db
699            .persist_metadata()
700            .map_err(|err| RedDBError::Internal(err.to_string()))?;
701        // Issue #120 — drop both the collection entries *and* every
702        // index entry that was scoped to this collection.  Dropping the
703        // collection wipes columns + collection-name + type-tags +
704        // index hits in one pass via `purge_collection_entries`, so
705        // the explicit `DropIndex` calls would be redundant.
706        self.schema_vocabulary_apply(
707            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
708                collection: query.name.clone(),
709            },
710        );
711
712        Ok(RuntimeQueryResult::ok_message(
713            raw_query.to_string(),
714            &format!("table '{}' dropped", query.name),
715            "drop",
716        ))
717    }
718
719    pub fn execute_drop_graph(
720        &self,
721        raw_query: &str,
722        query: &DropGraphQuery,
723    ) -> RedDBResult<RuntimeQueryResult> {
724        self.execute_drop_typed_collection(
725            raw_query,
726            &query.name,
727            query.if_exists,
728            CollectionModel::Graph,
729            "graph",
730        )
731    }
732
733    pub fn execute_drop_vector(
734        &self,
735        raw_query: &str,
736        query: &DropVectorQuery,
737    ) -> RedDBResult<RuntimeQueryResult> {
738        self.execute_drop_typed_collection(
739            raw_query,
740            &query.name,
741            query.if_exists,
742            CollectionModel::Vector,
743            "vector",
744        )
745    }
746
747    pub fn execute_drop_document(
748        &self,
749        raw_query: &str,
750        query: &DropDocumentQuery,
751    ) -> RedDBResult<RuntimeQueryResult> {
752        self.execute_drop_typed_collection(
753            raw_query,
754            &query.name,
755            query.if_exists,
756            CollectionModel::Document,
757            "document",
758        )
759    }
760
761    pub fn execute_drop_kv(
762        &self,
763        raw_query: &str,
764        query: &DropKvQuery,
765    ) -> RedDBResult<RuntimeQueryResult> {
766        let label = polymorphic_resolver::model_name(query.model);
767        self.execute_drop_typed_collection(
768            raw_query,
769            &query.name,
770            query.if_exists,
771            query.model,
772            label,
773        )
774    }
775
776    pub fn execute_drop_collection(
777        &self,
778        raw_query: &str,
779        query: &DropCollectionQuery,
780    ) -> RedDBResult<RuntimeQueryResult> {
781        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
782        if is_system_schema_name(&query.name) {
783            return Err(RedDBError::Query("system schema is read-only".to_string()));
784        }
785        let store = self.inner.db.store();
786        if store.get_collection(&query.name).is_none() {
787            if query.if_exists {
788                return Ok(RuntimeQueryResult::ok_message(
789                    raw_query.to_string(),
790                    &format!("collection '{}' does not exist", query.name),
791                    "drop",
792                ));
793            }
794            return Err(RedDBError::NotFound(format!(
795                "collection '{}' not found",
796                query.name
797            )));
798        }
799
800        let actual =
801            polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
802        if let Some(expected) = query.model {
803            polymorphic_resolver::ensure_model_match(expected, actual)?;
804        }
805
806        match actual {
807            CollectionModel::Table => self.execute_drop_table(
808                raw_query,
809                &DropTableQuery {
810                    name: query.name.clone(),
811                    if_exists: query.if_exists,
812                },
813            ),
814            CollectionModel::TimeSeries => self.execute_drop_timeseries(
815                raw_query,
816                &DropTimeSeriesQuery {
817                    name: query.name.clone(),
818                    if_exists: query.if_exists,
819                },
820            ),
821            CollectionModel::Queue => self.execute_drop_queue(
822                raw_query,
823                &DropQueueQuery {
824                    name: query.name.clone(),
825                    if_exists: query.if_exists,
826                },
827            ),
828            CollectionModel::Graph => self.execute_drop_graph(
829                raw_query,
830                &DropGraphQuery {
831                    name: query.name.clone(),
832                    if_exists: query.if_exists,
833                },
834            ),
835            CollectionModel::Vector => self.execute_drop_vector(
836                raw_query,
837                &DropVectorQuery {
838                    name: query.name.clone(),
839                    if_exists: query.if_exists,
840                },
841            ),
842            CollectionModel::Document => self.execute_drop_document(
843                raw_query,
844                &DropDocumentQuery {
845                    name: query.name.clone(),
846                    if_exists: query.if_exists,
847                },
848            ),
849            CollectionModel::Kv => self.execute_drop_kv(
850                raw_query,
851                &DropKvQuery {
852                    name: query.name.clone(),
853                    if_exists: query.if_exists,
854                    model: CollectionModel::Kv,
855                },
856            ),
857            CollectionModel::Config => self.execute_drop_kv(
858                raw_query,
859                &DropKvQuery {
860                    name: query.name.clone(),
861                    if_exists: query.if_exists,
862                    model: CollectionModel::Config,
863                },
864            ),
865            CollectionModel::Vault => self.execute_drop_kv(
866                raw_query,
867                &DropKvQuery {
868                    name: query.name.clone(),
869                    if_exists: query.if_exists,
870                    model: CollectionModel::Vault,
871                },
872            ),
873            CollectionModel::Hll => self.execute_probabilistic_command(
874                raw_query,
875                &ProbabilisticCommand::DropHll {
876                    name: query.name.clone(),
877                    if_exists: query.if_exists,
878                },
879            ),
880            CollectionModel::Sketch => self.execute_probabilistic_command(
881                raw_query,
882                &ProbabilisticCommand::DropSketch {
883                    name: query.name.clone(),
884                    if_exists: query.if_exists,
885                },
886            ),
887            CollectionModel::Filter => self.execute_probabilistic_command(
888                raw_query,
889                &ProbabilisticCommand::DropFilter {
890                    name: query.name.clone(),
891                    if_exists: query.if_exists,
892                },
893            ),
894            CollectionModel::Metrics => self.execute_drop_typed_collection(
895                raw_query,
896                &query.name,
897                query.if_exists,
898                CollectionModel::Metrics,
899                "metrics",
900            ),
901            CollectionModel::Mixed => self.execute_drop_typed_collection(
902                raw_query,
903                &query.name,
904                query.if_exists,
905                CollectionModel::Mixed,
906                "collection",
907            ),
908        }
909    }
910
911    /// Issue #801 — guard `ALTER GRAPH ... ADD|DROP ANALYTICS` so analytics
912    /// lifecycle mutations only land on a collection declared as a graph. The
913    /// `<graph>.<output>` resolver only consults `analytics_config` on
914    /// graph-model contracts, so allowing the op on any other model would write
915    /// dead config the reader can never surface.
916    fn require_graph_for_analytics(&self, name: &str) -> RedDBResult<()> {
917        let is_graph = self
918            .inner
919            .db
920            .collection_contract(name)
921            .map(|c| c.declared_model == crate::catalog::CollectionModel::Graph)
922            .unwrap_or(false);
923        if !is_graph {
924            return Err(RedDBError::Query(format!(
925                "ALTER GRAPH ... ANALYTICS: '{name}' is not a graph collection"
926            )));
927        }
928        Ok(())
929    }
930
931    /// Execute ALTER TABLE
932    ///
933    /// In RedDB's schema-on-read model, ALTER TABLE operations are advisory.
934    /// ADD COLUMN records the schema intent, DROP COLUMN removes it, and
935    /// RENAME COLUMN is a metadata rename.  Existing data is not rewritten.
936    pub fn execute_alter_table(
937        &self,
938        raw_query: &str,
939        query: &AlterTableQuery,
940    ) -> RedDBResult<RuntimeQueryResult> {
941        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
942        let expression_contract =
943            self.db()
944                .collection_contract(&query.name)
945                .is_some_and(|contract| {
946                    crate::application::collection_contract_enforcer::has_contract_expressions(
947                        &contract,
948                    )
949                });
950        if query.operations.iter().any(|operation| match operation {
951            AlterOperation::AddColumn(column) => {
952                expression_contract || column.generated.is_some() || column.check.is_some()
953            }
954            AlterOperation::DropColumn(_) | AlterOperation::RenameColumn { .. } => {
955                expression_contract
956            }
957            _ => false,
958        }) {
959            return Err(RedDBError::Query("ALTER of expression schemas requires validated backfill; create a new collection and import explicitly".to_string()));
960        }
961
962        let store = self.inner.db.store();
963
964        // Verify the table exists.
965        if store.get_collection(&query.name).is_none() {
966            return Err(RedDBError::NotFound(format!(
967                "table '{}' not found",
968                query.name
969            )));
970        }
971
972        let mut messages = Vec::new();
973
974        // Collect column-level changes upfront for schema-change event emission below.
975        let fields_added: Vec<String> = query
976            .operations
977            .iter()
978            .filter_map(|op| {
979                if let AlterOperation::AddColumn(col) = op {
980                    Some(col.name.clone())
981                } else {
982                    None
983                }
984            })
985            .collect();
986        let fields_removed: Vec<String> = query
987            .operations
988            .iter()
989            .filter_map(|op| {
990                if let AlterOperation::DropColumn(name) = op {
991                    Some(name.clone())
992                } else {
993                    None
994                }
995            })
996            .collect();
997
998        for op in &query.operations {
999            match op {
1000                AlterOperation::AddColumn(col) => {
1001                    // Schema-on-read: column will be available on next insert.
1002                    messages.push(format!("column '{}' added", col.name));
1003                }
1004                AlterOperation::DropColumn(name) => {
1005                    messages.push(format!("column '{}' dropped", name));
1006                }
1007                AlterOperation::RenameColumn { from, to } => {
1008                    messages.push(format!("column '{}' renamed to '{}'", from, to));
1009                }
1010                AlterOperation::AttachPartition { child, bound } => {
1011                    // Persist child → parent binding in red_config so the
1012                    // future planner-side pruner can enumerate children and
1013                    // evaluate their bounds.
1014                    store.set_config_tree(
1015                        &format!("partition.{}.children.{}", query.name, child),
1016                        &crate::serde_json::Value::String(bound.clone()),
1017                    );
1018                    messages.push(format!(
1019                        "partition '{child}' attached to '{}' ({bound})",
1020                        query.name
1021                    ));
1022                }
1023                AlterOperation::DetachPartition { child } => {
1024                    store.set_config_tree(
1025                        &format!("partition.{}.children.{}", query.name, child),
1026                        &crate::serde_json::Value::Null,
1027                    );
1028                    messages.push(format!(
1029                        "partition '{child}' detached from '{}'",
1030                        query.name
1031                    ));
1032                }
1033                AlterOperation::EnableRowLevelSecurity => {
1034                    self.inner
1035                        .rls_enabled_tables
1036                        .write()
1037                        .insert(query.name.clone());
1038                    // Persist flag so RLS survives restart via red_config.
1039                    store.set_config_tree(
1040                        &format!("rls.enabled.{}", query.name),
1041                        &crate::serde_json::Value::Bool(true),
1042                    );
1043                    self.invalidate_plan_cache();
1044                    messages.push(format!("row level security enabled on '{}'", query.name));
1045                    self.invalidate_result_cache();
1046                }
1047                AlterOperation::DisableRowLevelSecurity => {
1048                    self.inner.rls_enabled_tables.write().remove(&query.name);
1049                    store.set_config_tree(
1050                        &format!("rls.enabled.{}", query.name),
1051                        &crate::serde_json::Value::Null,
1052                    );
1053                    self.invalidate_plan_cache();
1054                    messages.push(format!("row level security disabled on '{}'", query.name));
1055                    self.invalidate_result_cache();
1056                }
1057                // Phase 2.5.4: retrofit tenancy onto an existing table.
1058                AlterOperation::EnableTenancy { column } => {
1059                    store.set_config_tree(
1060                        &format!("tenant_tables.{}.column", query.name),
1061                        &crate::serde_json::Value::String(column.clone()),
1062                    );
1063                    self.register_tenant_table(&query.name, column);
1064                    self.invalidate_plan_cache();
1065                    messages.push(format!(
1066                        "tenancy enabled on '{}' by column '{column}'",
1067                        query.name
1068                    ));
1069                }
1070                AlterOperation::DisableTenancy => {
1071                    store.set_config_tree(
1072                        &format!("tenant_tables.{}.column", query.name),
1073                        &crate::serde_json::Value::Null,
1074                    );
1075                    self.unregister_tenant_table(&query.name);
1076                    self.invalidate_plan_cache();
1077                    messages.push(format!("tenancy disabled on '{}'", query.name));
1078                }
1079                AlterOperation::SetAppendOnly(on) => {
1080                    // Contract is the single source of truth for the
1081                    // UPDATE/DELETE parse-time guard. The flag lands
1082                    // below via `apply_alter_operations_to_contract`;
1083                    // here we only publish the human-readable message.
1084                    messages.push(format!(
1085                        "append_only {} on '{}'",
1086                        if *on { "enabled" } else { "disabled" },
1087                        query.name
1088                    ));
1089                }
1090                AlterOperation::SetVersioned(on) => {
1091                    // Opt the collection into (or out of) Git-for-Data.
1092                    // Persists a row in red_vcs_settings; next AS OF /
1093                    // merge / diff against this table honours the
1094                    // flag. Retroactive: existing row versions whose
1095                    // xmins are still pinned by commits become
1096                    // reachable via AS OF COMMIT immediately.
1097                    self.vcs_set_versioned(&query.name, *on)?;
1098                    messages.push(format!(
1099                        "versioned {} on '{}'",
1100                        if *on { "enabled" } else { "disabled" },
1101                        query.name
1102                    ));
1103                }
1104                AlterOperation::EnableEvents(subscription) => {
1105                    let mut subscription = subscription.clone();
1106                    subscription.source = query.name.clone();
1107                    validate_event_subscriptions(
1108                        self,
1109                        &query.name,
1110                        std::slice::from_ref(&subscription),
1111                    )?;
1112                    ensure_event_target_queue(self, &subscription.target_queue)?;
1113                    messages.push(format!(
1114                        "events enabled on '{}' to '{}'",
1115                        query.name, subscription.target_queue
1116                    ));
1117                }
1118                AlterOperation::DisableEvents => {
1119                    messages.push(format!("events disabled on '{}'", query.name));
1120                }
1121                AlterOperation::AddSubscription { name, descriptor } => {
1122                    let mut sub = descriptor.clone();
1123                    sub.name = name.clone();
1124                    sub.source = query.name.clone();
1125                    validate_event_subscriptions(self, &query.name, std::slice::from_ref(&sub))?;
1126                    ensure_event_target_queue(self, &sub.target_queue)?;
1127                    messages.push(format!(
1128                        "subscription '{}' added on '{}' to '{}'",
1129                        name, query.name, sub.target_queue
1130                    ));
1131                }
1132                AlterOperation::DropSubscription { name } => {
1133                    messages.push(format!(
1134                        "subscription '{}' dropped on '{}'",
1135                        name, query.name
1136                    ));
1137                }
1138                AlterOperation::AddSigner { pubkey } => {
1139                    // Issue #522 — admin-gated registry mutation. The
1140                    // standard DDL `check_write` above gates by role; we
1141                    // additionally verify the collection actually has a
1142                    // signed-writes registry installed so this isn't a
1143                    // covert way to retrofit one (use `CREATE COLLECTION
1144                    // ... SIGNED_BY (...)` for that).
1145                    if !crate::runtime::signed_writes_kind::is_signed(&store, &query.name) {
1146                        return Err(RedDBError::Query(format!(
1147                            "ALTER COLLECTION ADD SIGNER: '{}' has no signer registry; \
1148                             recreate it with CREATE COLLECTION ... SIGNED_BY (...)",
1149                            query.name
1150                        )));
1151                    }
1152                    let actor = crate::runtime::impl_core::current_user_projected()
1153                        .unwrap_or_else(|| "@system/alter".to_string());
1154                    let changed = crate::runtime::signed_writes_kind::add_signer(
1155                        &store,
1156                        &query.name,
1157                        *pubkey,
1158                        &actor,
1159                    );
1160                    messages.push(format!(
1161                        "signer {} on '{}'",
1162                        if changed { "added" } else { "already present" },
1163                        query.name
1164                    ));
1165                }
1166                AlterOperation::RevokeSigner { pubkey } => {
1167                    if !crate::runtime::signed_writes_kind::is_signed(&store, &query.name) {
1168                        return Err(RedDBError::Query(format!(
1169                            "ALTER COLLECTION REVOKE SIGNER: '{}' has no signer registry",
1170                            query.name
1171                        )));
1172                    }
1173                    let actor = crate::runtime::impl_core::current_user_projected()
1174                        .unwrap_or_else(|| "@system/alter".to_string());
1175                    let changed = crate::runtime::signed_writes_kind::revoke_signer(
1176                        &store,
1177                        &query.name,
1178                        pubkey,
1179                        &actor,
1180                    );
1181                    messages.push(format!(
1182                        "signer {} on '{}'",
1183                        if changed {
1184                            "revoked"
1185                        } else {
1186                            "already revoked"
1187                        },
1188                        query.name
1189                    ));
1190                }
1191                AlterOperation::SetRetention { duration_ms } => {
1192                    // Issue #580 — validate that the collection has a
1193                    // timestamp column the lazy-on-scan filter can key
1194                    // off. Without one there is no anchor for "older
1195                    // than now - duration"; reject at ALTER time so the
1196                    // policy can never silently hide all rows.
1197                    let existing = self.inner.db.collection_contract(&query.name);
1198                    let has_ts_column = existing
1199                        .as_ref()
1200                        .map(retention_timestamp_column_exists)
1201                        .unwrap_or(false);
1202                    if !has_ts_column {
1203                        return Err(RedDBError::Query(format!(
1204                            "ALTER COLLECTION SET RETENTION: '{}' has no timestamp \
1205                             column — declare a TIMESTAMP/TIMESTAMPMS/DATETIME column \
1206                             or enable WITH timestamps = true before setting a \
1207                             retention policy",
1208                            query.name
1209                        )));
1210                    }
1211                    messages.push(format!(
1212                        "retention set to {duration_ms} ms on '{}'",
1213                        query.name
1214                    ));
1215                }
1216                AlterOperation::UnsetRetention => {
1217                    messages.push(format!("retention cleared on '{}'", query.name));
1218                }
1219                AlterOperation::AddAnalytics(views) => {
1220                    // Issue #801 — analytics lifecycle is graph-only. Reject the
1221                    // op on a non-graph collection so a misrouted ALTER fails
1222                    // loudly instead of silently writing analytics_config onto a
1223                    // model whose reader never resolves `<name>.<output>`.
1224                    self.require_graph_for_analytics(&query.name)?;
1225                    let existing = self.inner.db.collection_contract(&query.name);
1226                    let enabled: std::collections::BTreeSet<crate::catalog::AnalyticsOutput> =
1227                        existing
1228                            .as_ref()
1229                            .map(|c| c.analytics_config.iter().map(|v| v.output).collect())
1230                            .unwrap_or_default();
1231                    for view in views {
1232                        if enabled.contains(&view.output) {
1233                            // Idempotent: adding an already-enabled output is a
1234                            // no-op — no error, no duplicate state.
1235                            messages.push(format!(
1236                                "analytics '{}' already enabled on '{}'",
1237                                view.output.as_str(),
1238                                query.name
1239                            ));
1240                        } else {
1241                            messages.push(format!(
1242                                "analytics '{}' enabled on '{}'",
1243                                view.output.as_str(),
1244                                query.name
1245                            ));
1246                        }
1247                    }
1248                }
1249                AlterOperation::DropAnalytics(output) => {
1250                    self.require_graph_for_analytics(&query.name)?;
1251                    let enabled = self
1252                        .inner
1253                        .db
1254                        .collection_contract(&query.name)
1255                        .map(|c| c.analytics_config.iter().any(|v| v.output == *output))
1256                        .unwrap_or(false);
1257                    if !enabled {
1258                        // Dropping an output that was never enabled is a clean,
1259                        // explicit error (AC4) rather than a silent no-op.
1260                        return Err(RedDBError::Query(format!(
1261                            "ALTER GRAPH DROP ANALYTICS: analytics output '{}' is not enabled on graph '{}'",
1262                            output.as_str(),
1263                            query.name
1264                        )));
1265                    }
1266                    messages.push(format!(
1267                        "analytics '{}' disabled on '{}'",
1268                        output.as_str(),
1269                        query.name
1270                    ));
1271                }
1272            }
1273        }
1274
1275        let mut contract = self
1276            .inner
1277            .db
1278            .collection_contract(&query.name)
1279            .unwrap_or_else(|| default_collection_contract_for_existing_table(&query.name));
1280        apply_alter_operations_to_contract(&mut contract, &query.operations);
1281        contract.version = contract.version.saturating_add(1);
1282        contract.updated_at_unix_ms = current_unix_ms();
1283        self.inner
1284            .db
1285            .save_collection_contract(contract)
1286            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1287        // Issue #301 — emit OperatorEvent when column schema changes on a
1288        // collection that has active event subscriptions, so operators know
1289        // downstream consumers may see a different payload shape.
1290        if !fields_added.is_empty() || !fields_removed.is_empty() {
1291            let sub_names: Vec<String> = self
1292                .inner
1293                .db
1294                .collection_contract(&query.name)
1295                .map(|c| {
1296                    c.subscriptions
1297                        .iter()
1298                        .filter(|s| s.enabled)
1299                        .map(|s| s.name.clone())
1300                        .collect()
1301                })
1302                .unwrap_or_default();
1303            if !sub_names.is_empty() {
1304                crate::telemetry::operator_event::OperatorEvent::SubscriptionSchemaChange {
1305                    collection: query.name.clone(),
1306                    subscription_names: sub_names.join(", "),
1307                    fields_added: fields_added.join(", "),
1308                    fields_removed: fields_removed.join(", "),
1309                    lsn: self.cdc_current_lsn(),
1310                }
1311                .emit_global();
1312            }
1313        }
1314
1315        self.clear_table_planner_stats(&query.name);
1316        self.invalidate_result_cache();
1317        // Issue #120 — refresh the schema-vocabulary entries from the
1318        // post-ALTER contract. Drop+recreate inside the index keeps
1319        // the invalidation guarantee complete (no stale columns from
1320        // before an ALTER ... DROP COLUMN).
1321        let post_alter_columns: Vec<String> = self
1322            .inner
1323            .db
1324            .collection_contract(&query.name)
1325            .map(|contract| {
1326                contract
1327                    .declared_columns
1328                    .iter()
1329                    .map(|col| col.name.clone())
1330                    .collect()
1331            })
1332            .unwrap_or_default();
1333        self.schema_vocabulary_apply(
1334            crate::runtime::schema_vocabulary::DdlEvent::AlterCollection {
1335                collection: query.name.clone(),
1336                columns: post_alter_columns,
1337                type_tags: Vec::new(),
1338                description: None,
1339            },
1340        );
1341
1342        let message = if messages.is_empty() {
1343            format!("table '{}' altered (no operations)", query.name)
1344        } else {
1345            format!("table '{}' altered: {}", query.name, messages.join(", "))
1346        };
1347
1348        Ok(RuntimeQueryResult::ok_message(
1349            raw_query.to_string(),
1350            &message,
1351            "alter",
1352        ))
1353    }
1354
1355    pub fn execute_create_vcs_ref(
1356        &self,
1357        raw_query: &str,
1358        query: &CreateVcsRefQuery,
1359    ) -> RedDBResult<RuntimeQueryResult> {
1360        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1361        let connection_id = crate::runtime::impl_core::current_connection_id();
1362        match query.kind {
1363            VcsRefKind::Branch => {
1364                self.vcs_branch_create(crate::application::vcs::CreateBranchInput {
1365                    name: query.name.clone(),
1366                    from: query.target.clone(),
1367                    connection_id,
1368                })?;
1369            }
1370            VcsRefKind::Tag => {
1371                let target = match &query.target {
1372                    Some(target) => target.clone(),
1373                    None => self
1374                        .vcs_status(crate::application::vcs::StatusInput { connection_id })?
1375                        .head_commit
1376                        .filter(|head| !head.is_empty())
1377                        .ok_or_else(|| {
1378                            RedDBError::InvalidConfig(
1379                                "cannot create tag: HEAD has no commits".to_string(),
1380                            )
1381                        })?,
1382                };
1383                self.vcs_tag_create(crate::application::vcs::CreateTagInput {
1384                    name: query.name.clone(),
1385                    target,
1386                    annotation: None,
1387                })?;
1388            }
1389        }
1390        let kind = match query.kind {
1391            VcsRefKind::Branch => "branch",
1392            VcsRefKind::Tag => "tag",
1393        };
1394        Ok(RuntimeQueryResult::ok_message(
1395            raw_query.to_string(),
1396            &format!("{kind} '{}' created", query.name),
1397            "create",
1398        ))
1399    }
1400
1401    pub fn execute_drop_vcs_ref(
1402        &self,
1403        raw_query: &str,
1404        query: &DropVcsRefQuery,
1405    ) -> RedDBResult<RuntimeQueryResult> {
1406        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1407        match query.kind {
1408            VcsRefKind::Branch => self.vcs_branch_delete(&query.name)?,
1409            VcsRefKind::Tag => self.vcs_tag_delete(&query.name)?,
1410        }
1411        let kind = match query.kind {
1412            VcsRefKind::Branch => "branch",
1413            VcsRefKind::Tag => "tag",
1414        };
1415        Ok(RuntimeQueryResult::ok_message(
1416            raw_query.to_string(),
1417            &format!("{kind} '{}' dropped", query.name),
1418            "drop",
1419        ))
1420    }
1421
1422    pub fn execute_fork_store(
1423        &self,
1424        raw_query: &str,
1425        query: &ForkStoreQuery,
1426    ) -> RedDBResult<RuntimeQueryResult> {
1427        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1428        let path = self.inner.db.path().ok_or_else(|| {
1429            RedDBError::Query("FORK STORE requires a persistent store".to_string())
1430        })?;
1431        let durable_lsn = self.cdc_current_lsn();
1432        let fork_lsn = query.at_lsn.unwrap_or(durable_lsn);
1433        let retention_floor = operational_wal_retention_floor(durable_lsn);
1434        validate_store_fork_lsn(fork_lsn, retention_floor, durable_lsn)?;
1435
1436        let manifest = reddb_file::OperationalManifest::for_db_path(path);
1437        let mut collections = self.inner.db.store().list_collections();
1438        collections.sort();
1439        manifest.recover_or_bootstrap(&collections).map_err(|err| {
1440            RedDBError::Query(format!("failed to prepare store fork manifest: {err}"))
1441        })?;
1442        manifest
1443            .create_fork(&query.name, fork_lsn)
1444            .map_err(|err| RedDBError::Query(format!("failed to create store fork: {err}")))?;
1445        Ok(RuntimeQueryResult::ok_message(
1446            raw_query.to_string(),
1447            &format!("store fork '{}' created at LSN {fork_lsn}", query.name),
1448            "fork_store",
1449        ))
1450    }
1451
1452    pub fn execute_drop_fork(
1453        &self,
1454        raw_query: &str,
1455        query: &DropForkQuery,
1456    ) -> RedDBResult<RuntimeQueryResult> {
1457        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1458        let path = self.inner.db.path().ok_or_else(|| {
1459            RedDBError::Query("DROP FORK requires a persistent store".to_string())
1460        })?;
1461        let manifest = reddb_file::OperationalManifest::for_db_path(path);
1462        let dropped = manifest
1463            .drop_fork(&query.name)
1464            .map_err(|err| RedDBError::Query(format!("failed to drop store fork: {err}")))?;
1465        if !dropped && !query.if_exists {
1466            return Err(RedDBError::NotFound(format!("store fork '{}'", query.name)));
1467        }
1468        Ok(RuntimeQueryResult::ok_message(
1469            raw_query.to_string(),
1470            &format!("store fork '{}' dropped", query.name),
1471            "drop_fork",
1472        ))
1473    }
1474
1475    pub fn execute_promote_fork(
1476        &self,
1477        raw_query: &str,
1478        query: &PromoteForkQuery,
1479    ) -> RedDBResult<RuntimeQueryResult> {
1480        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1481        let path = self.inner.db.path().ok_or_else(|| {
1482            RedDBError::Query("PROMOTE FORK requires a persistent store".to_string())
1483        })?;
1484        self.flush()?;
1485        let manifest = reddb_file::OperationalManifest::for_db_path(path);
1486        let outcome = manifest
1487            .promote_fork(&query.name)
1488            .map_err(|err| RedDBError::Query(format!("failed to promote store fork: {err}")))?;
1489        let outcome =
1490            outcome.ok_or_else(|| RedDBError::NotFound(format!("store fork '{}'", query.name)))?;
1491        Ok(RuntimeQueryResult::ok_message(
1492            raw_query.to_string(),
1493            &format!(
1494                "store fork '{}' promoted at LSN {}; retired parent archived at {}",
1495                outcome.name,
1496                outcome.fork_lsn,
1497                outcome.archived_parent.store_identity()
1498            ),
1499            "promote_fork",
1500        ))
1501    }
1502
1503    /// Execute EXPLAIN ALTER FOR CREATE TABLE
1504    ///
1505    /// Pure read: computes the schema diff between the target table's
1506    /// current `CollectionContract` and the embedded `CREATE TABLE` body,
1507    /// and returns it as SQL `ALTER TABLE` text (default) or structured
1508    /// JSON. Never mutates storage.
1509    pub fn execute_explain_alter(
1510        &self,
1511        raw_query: &str,
1512        query: &ExplainAlterQuery,
1513    ) -> RedDBResult<RuntimeQueryResult> {
1514        // Validate the target CREATE TABLE body so syntactically valid
1515        // but semantically broken targets (bad SQL types, duplicate
1516        // columns) are caught here rather than inside the diff engine.
1517        let analyzed = analyze_create_table(&query.target)
1518            .map_err(|err| RedDBError::Query(err.to_string()))?;
1519
1520        let current_contract = self.inner.db.collection_contract(&query.target.name);
1521
1522        // The column-diff planner cannot emit ADD/DROP CONSTRAINT. Do not
1523        // silently report a complete migration when table-level UNIQUE changes.
1524        let current_unique = current_contract
1525            .as_ref()
1526            .map(|contract| {
1527                contract
1528                    .table_def
1529                    .iter()
1530                    .flat_map(|table| &table.constraints)
1531                    .filter(|constraint| {
1532                        constraint.constraint_type == reddb_types::ConstraintType::Unique
1533                            && !contract.declared_columns.iter().any(|column| {
1534                                column.unique
1535                                    && constraint.columns == [column.name.clone()]
1536                                    && constraint.name == format!("uniq_{}", column.name)
1537                            })
1538                    })
1539                    .map(|constraint| (constraint.name.clone(), constraint.columns.clone()))
1540                    .collect::<BTreeSet<_>>()
1541            })
1542            .unwrap_or_default();
1543        let target_unique = analyzed
1544            .unique_constraints
1545            .into_iter()
1546            .map(|constraint| (constraint.name, constraint.columns))
1547            .collect::<BTreeSet<_>>();
1548        if current_unique != target_unique {
1549            return Err(RedDBError::Query("NOT_YET_SUPPORTED: EXPLAIN ALTER cannot plan changes to table-level UNIQUE constraints".to_string()));
1550        }
1551
1552        let current_columns: Vec<crate::physical::DeclaredColumnContract> = current_contract
1553            .as_ref()
1554            .map(|c| c.declared_columns.clone())
1555            .unwrap_or_default();
1556
1557        let diff = super::schema_diff::compute_column_diff(
1558            &query.target.name,
1559            &current_columns,
1560            &query.target.columns,
1561        );
1562
1563        let rendered = match query.format {
1564            ExplainFormat::Sql => super::schema_diff::format_as_sql(&diff),
1565            ExplainFormat::Json => super::schema_diff::format_as_json(&diff),
1566        };
1567
1568        let format_label = match query.format {
1569            ExplainFormat::Sql => "sql",
1570            ExplainFormat::Json => "json",
1571        };
1572
1573        let columns = vec![
1574            "table".to_string(),
1575            "format".to_string(),
1576            "diff".to_string(),
1577        ];
1578        let row = vec![
1579            ("table".to_string(), Value::text(query.target.name.clone())),
1580            ("format".to_string(), Value::text(format_label.to_string())),
1581            ("diff".to_string(), Value::text(rendered)),
1582        ];
1583
1584        Ok(RuntimeQueryResult::ok_records(
1585            raw_query.to_string(),
1586            columns,
1587            vec![row],
1588            "explain",
1589        ))
1590    }
1591
1592    /// Execute CREATE INDEX
1593    ///
1594    /// Registers a new index on a collection, builds it from existing data,
1595    /// and makes it available to the query executor for O(1) lookups.
1596    pub fn execute_create_index(
1597        &self,
1598        raw_query: &str,
1599        query: &CreateIndexQuery,
1600    ) -> RedDBResult<RuntimeQueryResult> {
1601        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1602        let topology_lock = self
1603            .inner
1604            .index_store
1605            .collection_topology_lock(&query.table);
1606        let _topology_guard = topology_lock.write();
1607        let store = self.inner.db.store();
1608
1609        // Verify the table exists
1610        let manager = store
1611            .get_collection(&query.table)
1612            .ok_or_else(|| RedDBError::NotFound(format!("table '{}' not found", query.table)))?;
1613
1614        let method_kind = match query.method {
1615            IndexMethod::Hash => super::index_store::IndexMethodKind::Hash,
1616            IndexMethod::BTree => super::index_store::IndexMethodKind::BTree,
1617            IndexMethod::Bitmap => super::index_store::IndexMethodKind::Bitmap,
1618            // Generic spatial request → engine default spatial backend
1619            // (disk-resident H3 as of PRD #1574 slice 4, #1578).
1620            IndexMethod::Spatial => super::index_store::IndexMethodKind::default_spatial(),
1621            IndexMethod::H3 { resolution } => {
1622                super::index_store::IndexMethodKind::H3 { resolution }
1623            }
1624        };
1625
1626        // Extract fields from existing entities for indexing. Row
1627        // entities may arrive in either the "named" HashMap layout
1628        // (gRPC `BulkInsertBinary` path) OR the columnar layout
1629        // (HTTP `POST /collections/X/bulk/rows` fast path, which uses
1630        // `schema: Some(Arc<Vec<String>>)` + `columns: Vec<Value>` and
1631        // leaves `named == None`). Prior to this commit the columnar
1632        // branch returned an empty field list, so `CREATE INDEX` built
1633        // a zero-entity index over HTTP-inserted data even though the
1634        // data was queryable via `SELECT`.
1635        let entities = manager.query_all(|_| true);
1636        let entity_fields: Vec<(crate::storage::unified::EntityId, Vec<(String, Value)>)> =
1637            entities
1638                .iter()
1639                .map(|e| {
1640                    let fields = match &e.data {
1641                        crate::storage::EntityData::Row(row) => {
1642                            if let Some(ref named) = row.named {
1643                                named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1644                            } else if let Some(ref schema) = row.schema {
1645                                // Columnar layout — pair each column
1646                                // with its positional name from the
1647                                // shared schema Arc.
1648                                schema
1649                                    .iter()
1650                                    .zip(row.columns.iter())
1651                                    .map(|(k, v)| (k.clone(), v.clone()))
1652                                    .collect()
1653                            } else {
1654                                Vec::new()
1655                            }
1656                        }
1657                        crate::storage::EntityData::Node(node) => node
1658                            .properties
1659                            .iter()
1660                            .map(|(k, v)| (k.clone(), v.clone()))
1661                            .collect(),
1662                        crate::storage::EntityData::TimeSeries(ts) => ts
1663                            .fields
1664                            .iter()
1665                            .map(|(k, v)| (k.clone(), v.clone()))
1666                            .collect(),
1667                        _ => Vec::new(),
1668                    };
1669                    (e.id, fields)
1670                })
1671                .collect();
1672
1673        let index_growth_rows = entity_fields
1674            .iter()
1675            .map(|(_, fields)| fields.clone())
1676            .collect::<Vec<_>>();
1677        let _reservation = self.admit_non_evictable_growth(
1678            crate::storage::memory_pools::MemoryPool::IndexMemory,
1679            &format!("create index {}", query.name),
1680            crate::runtime::memory_admission::estimate_index_growth(
1681                &index_growth_rows,
1682                &query.columns,
1683            ),
1684        )?;
1685
1686        // Build the index
1687        let indexed_count = self
1688            .inner
1689            .index_store
1690            .create_index(
1691                &query.name,
1692                &query.table,
1693                &query.columns,
1694                method_kind,
1695                query.unique,
1696                &entity_fields,
1697            )
1698            .map_err(RedDBError::Internal)?;
1699
1700        let analyzed = crate::storage::query::planner::stats_catalog::analyze_entity_fields(
1701            &query.table,
1702            &entity_fields,
1703        );
1704        crate::storage::query::planner::stats_catalog::persist_table_stats(&store, &analyzed);
1705        self.invalidate_plan_cache();
1706
1707        // Register metadata
1708        self.inner
1709            .index_store
1710            .register(super::index_store::RegisteredIndex {
1711                name: query.name.clone(),
1712                collection: query.table.clone(),
1713                columns: query.columns.clone(),
1714                method: method_kind,
1715                unique: query.unique,
1716            });
1717        self.persist_runtime_index_descriptor(super::index_store::RegisteredIndex {
1718            name: query.name.clone(),
1719            collection: query.table.clone(),
1720            columns: query.columns.clone(),
1721            method: method_kind,
1722            unique: query.unique,
1723        })?;
1724        // Issue #120 — surface the index name + indexed columns in
1725        // the schema-vocabulary so AskPipeline (#121) can resolve
1726        // "the email index" back to its collection.
1727        self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::CreateIndex {
1728            collection: query.table.clone(),
1729            index: query.name.clone(),
1730            columns: query.columns.clone(),
1731        });
1732
1733        let method_str = format!("{}", query.method);
1734        let unique_str = if query.unique { "unique " } else { "" };
1735        let cols = query.columns.join(", ");
1736        let total_count = entity_fields.len();
1737        let coverage_detail =
1738            if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. })
1739                && total_count > 0
1740                && indexed_count == 0
1741            {
1742                format!(
1743                    "{} of {} entities indexed — no indexable geo value in '{}'; expected {}",
1744                    indexed_count,
1745                    total_count,
1746                    cols,
1747                    crate::geo::RECOGNIZED_GEO_SHAPES
1748                )
1749            } else if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. }) {
1750                format!("{} of {} entities indexed", indexed_count, total_count)
1751            } else {
1752                format!("{} entities indexed", indexed_count)
1753            };
1754
1755        Ok(RuntimeQueryResult::ok_message(
1756            raw_query.to_string(),
1757            &format!(
1758                "{}index '{}' created on '{}' ({}) using {} ({})",
1759                unique_str, query.name, query.table, cols, method_str, coverage_detail
1760            ),
1761            "create",
1762        ))
1763    }
1764
1765    /// Execute DROP INDEX
1766    ///
1767    /// Removes an index from a collection.
1768    pub fn execute_drop_index(
1769        &self,
1770        raw_query: &str,
1771        query: &DropIndexQuery,
1772    ) -> RedDBResult<RuntimeQueryResult> {
1773        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1774        let topology_lock = self
1775            .inner
1776            .index_store
1777            .collection_topology_lock(&query.table);
1778        let _topology_guard = topology_lock.write();
1779        let store = self.inner.db.store();
1780
1781        // Verify the table exists
1782        if store.get_collection(&query.table).is_none() {
1783            if query.if_exists {
1784                return Ok(RuntimeQueryResult::ok_message(
1785                    raw_query.to_string(),
1786                    &format!("table '{}' does not exist", query.table),
1787                    "drop",
1788                ));
1789            }
1790            return Err(RedDBError::NotFound(format!(
1791                "table '{}' not found",
1792                query.table
1793            )));
1794        }
1795
1796        // Remove from IndexStore
1797        self.inner.index_store.drop_index(&query.name, &query.table);
1798        self.persist_runtime_index_drop(&query.table, &query.name)?;
1799        self.invalidate_plan_cache();
1800        // Issue #120 — keep the schema-vocabulary index entry in sync.
1801        self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::DropIndex {
1802            collection: query.table.clone(),
1803            index: query.name.clone(),
1804        });
1805
1806        Ok(RuntimeQueryResult::ok_message(
1807            raw_query.to_string(),
1808            &format!("index '{}' dropped from '{}'", query.name, query.table),
1809            "drop",
1810        ))
1811    }
1812
1813    fn execute_drop_typed_collection(
1814        &self,
1815        raw_query: &str,
1816        name: &str,
1817        if_exists: bool,
1818        expected_model: CollectionModel,
1819        label: &str,
1820    ) -> RedDBResult<RuntimeQueryResult> {
1821        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1822        if is_system_schema_name(name) {
1823            return Err(RedDBError::Query("system schema is read-only".to_string()));
1824        }
1825        let store = self.inner.db.store();
1826        if store.get_collection(name).is_none() {
1827            if if_exists {
1828                return Ok(RuntimeQueryResult::ok_message(
1829                    raw_query.to_string(),
1830                    &format!("{label} '{name}' does not exist"),
1831                    "drop",
1832                ));
1833            }
1834            return Err(RedDBError::NotFound(format!("{label} '{name}' not found")));
1835        }
1836
1837        let actual = self
1838            .inner
1839            .db
1840            .collection_contract(name)
1841            .map(|contract| contract.declared_model)
1842            .map(Ok)
1843            .unwrap_or_else(|| {
1844                polymorphic_resolver::resolve(name, &self.inner.db.catalog_model_snapshot())
1845            })?;
1846        polymorphic_resolver::ensure_model_match(expected_model, actual)?;
1847        self.drop_collection_storage(raw_query, name, label)
1848    }
1849
1850    pub fn execute_truncate(
1851        &self,
1852        raw_query: &str,
1853        query: &TruncateQuery,
1854    ) -> RedDBResult<RuntimeQueryResult> {
1855        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1856        if is_system_schema_name(&query.name) {
1857            return Err(RedDBError::Query("system schema is read-only".to_string()));
1858        }
1859
1860        let label = query
1861            .model
1862            .map(polymorphic_resolver::model_name)
1863            .unwrap_or("collection");
1864        let store = self.inner.db.store();
1865        if store.get_collection(&query.name).is_none() {
1866            if query.if_exists {
1867                return Ok(RuntimeQueryResult::ok_message(
1868                    raw_query.to_string(),
1869                    &format!("{label} '{}' does not exist", query.name),
1870                    "truncate",
1871                ));
1872            }
1873            return Err(RedDBError::NotFound(format!(
1874                "{label} '{}' not found",
1875                query.name
1876            )));
1877        }
1878
1879        let actual =
1880            polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
1881        if let Some(expected) = query.model {
1882            polymorphic_resolver::ensure_model_match(expected, actual)?;
1883        }
1884
1885        if actual == CollectionModel::Queue {
1886            return self.execute_queue_command(
1887                raw_query,
1888                &QueueCommand::Purge {
1889                    queue: query.name.clone(),
1890                },
1891            );
1892        }
1893
1894        // Count before wiping so we can emit the aggregated truncate event.
1895        let affected = self.truncate_collection_entities(&query.name)?;
1896        // Emit 1 truncate event (not N delete events) for event-enabled collections.
1897        crate::runtime::mutation::emit_truncate_event_for_collection(self, &query.name, affected)?;
1898        self.inner.db.invalidate_vector_index(&query.name);
1899        self.clear_table_planner_stats(&query.name);
1900        self.invalidate_result_cache();
1901
1902        Ok(RuntimeQueryResult::ok_message(
1903            raw_query.to_string(),
1904            &format!(
1905                "{affected} entities truncated from {label} '{}'",
1906                query.name
1907            ),
1908            "truncate",
1909        ))
1910    }
1911
1912    fn truncate_collection_entities(&self, name: &str) -> RedDBResult<u64> {
1913        let store = self.inner.db.store();
1914        let Some(manager) = store.get_collection(name) else {
1915            return Ok(0);
1916        };
1917        let entities = manager.query_all(|_| true);
1918        if entities.is_empty() {
1919            return Ok(0);
1920        }
1921
1922        for entity in &entities {
1923            let fields = entity_index_fields(&entity.data);
1924            self.inner
1925                .index_store
1926                .index_entity_delete(name, entity.id, &fields)
1927                .map_err(RedDBError::Internal)?;
1928        }
1929
1930        let ids = entities.iter().map(|entity| entity.id).collect::<Vec<_>>();
1931        let deleted_ids = store
1932            .delete_batch(name, &ids)
1933            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1934        for id in &deleted_ids {
1935            store.context_index().remove_entity(*id);
1936        }
1937        Ok(deleted_ids.len() as u64)
1938    }
1939
1940    fn drop_collection_storage(
1941        &self,
1942        raw_query: &str,
1943        name: &str,
1944        label: &str,
1945    ) -> RedDBResult<RuntimeQueryResult> {
1946        let store = self.inner.db.store();
1947
1948        // Emit 1 collection_dropped event before storage is wiped.
1949        // Queue is preserved; subscription is removed with the contract below.
1950        let final_count = store
1951            .get_collection(name)
1952            .map(|manager| manager.query_all(|_| true).len() as u64)
1953            .unwrap_or(0);
1954        crate::runtime::mutation::emit_collection_dropped_event_for_collection(
1955            self,
1956            name,
1957            final_count,
1958        )?;
1959
1960        let topology_lock = self.inner.index_store.collection_topology_lock(name);
1961        let _topology_guard = topology_lock.write();
1962        let orphaned_indices: Vec<String> = self
1963            .inner
1964            .index_store
1965            .list_indices(name)
1966            .into_iter()
1967            .map(|index| index.name)
1968            .collect();
1969        for index_name in &orphaned_indices {
1970            self.inner.index_store.drop_index(index_name, name);
1971        }
1972
1973        store
1974            .drop_collection(name)
1975            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1976        self.inner.db.invalidate_vector_index(name);
1977        self.inner.db.clear_collection_default_ttl_ms(name);
1978        self.inner
1979            .db
1980            .remove_collection_contract(name)
1981            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1982        self.clear_table_planner_stats(name);
1983        self.invalidate_result_cache();
1984        if let Some(store) = self.inner.auth_store.read().clone() {
1985            store.invalidate_visible_collections_cache();
1986        }
1987        self.inner
1988            .db
1989            .persist_metadata()
1990            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1991        self.schema_vocabulary_apply(
1992            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
1993                collection: name.to_string(),
1994            },
1995        );
1996
1997        Ok(RuntimeQueryResult::ok_message(
1998            raw_query.to_string(),
1999            &format!("{label} '{name}' dropped"),
2000            "drop",
2001        ))
2002    }
2003}
2004
2005pub(crate) fn is_system_schema_name(name: &str) -> bool {
2006    name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_")
2007}
2008
2009fn entity_index_fields(data: &EntityData) -> Vec<(String, Value)> {
2010    match data {
2011        EntityData::Row(row) => {
2012            if let Some(ref named) = row.named {
2013                named
2014                    .iter()
2015                    .map(|(key, value)| (key.clone(), value.clone()))
2016                    .collect()
2017            } else if let Some(ref schema) = row.schema {
2018                schema
2019                    .iter()
2020                    .zip(row.columns.iter())
2021                    .map(|(key, value)| (key.clone(), value.clone()))
2022                    .collect()
2023            } else {
2024                Vec::new()
2025            }
2026        }
2027        EntityData::Node(node) => node
2028            .properties
2029            .iter()
2030            .map(|(key, value)| (key.clone(), value.clone()))
2031            .collect(),
2032        _ => Vec::new(),
2033    }
2034}
2035
2036/// DDL-time gate for a declared AI policy (issue #1271). Each declared
2037/// modality block is checked against the provider capability matrix
2038/// (#1269): a provider/model that cannot serve the requested modality is
2039/// rejected before the collection is created. Uses the built-in registry
2040/// (no per-deployment overrides at DDL time).
2041fn validate_ai_policy_modalities(policy: &crate::catalog::AiPolicy) -> RedDBResult<()> {
2042    use crate::runtime::ai::provider_capabilities::{Modality, Registry};
2043    let registry = Registry::new();
2044    let check = |provider: &str, model: &str, modality: Modality| -> RedDBResult<()> {
2045        registry
2046            .validate_policy_modality(provider, model, modality)
2047            .map_err(|err| RedDBError::Query(err.to_string()))
2048    };
2049    if let Some(embed) = &policy.embed {
2050        check(&embed.provider, &embed.model, Modality::Embed)?;
2051    }
2052    if let Some(moderate) = &policy.moderate {
2053        check(&moderate.provider, &moderate.model, Modality::Moderate)?;
2054    }
2055    if let Some(vision) = &policy.vision {
2056        check(&vision.provider, &vision.model, Modality::Vision)?;
2057    }
2058    Ok(())
2059}
2060
2061fn collection_contract_from_create_table(
2062    query: &CreateTableQuery,
2063) -> RedDBResult<crate::physical::CollectionContract> {
2064    let now = current_unix_ms();
2065    let mut declared_columns: Vec<crate::physical::DeclaredColumnContract> = query
2066        .columns
2067        .iter()
2068        .map(declared_column_contract_from_ddl)
2069        .collect();
2070    if query.timestamps {
2071        // Opt-in `WITH timestamps = true` auto-adds two user-visible
2072        // columns that the write path populates from
2073        // UnifiedEntity::created_at/updated_at. BIGINT unix-ms, NOT NULL.
2074        declared_columns.push(crate::physical::DeclaredColumnContract {
2075            name: "created_at".to_string(),
2076            data_type: "BIGINT".to_string(),
2077            sql_type: Some(reddb_types::SqlTypeName::simple("BIGINT")),
2078            not_null: true,
2079            default: None,
2080            generated: None,
2081            check: None,
2082            compress: None,
2083            unique: false,
2084            primary_key: false,
2085            enum_variants: Vec::new(),
2086            array_element: None,
2087            decimal_precision: None,
2088        });
2089        declared_columns.push(crate::physical::DeclaredColumnContract {
2090            name: "updated_at".to_string(),
2091            data_type: "BIGINT".to_string(),
2092            sql_type: Some(reddb_types::SqlTypeName::simple("BIGINT")),
2093            not_null: true,
2094            default: None,
2095            generated: None,
2096            check: None,
2097            compress: None,
2098            unique: false,
2099            primary_key: false,
2100            enum_variants: Vec::new(),
2101            array_element: None,
2102            decimal_precision: None,
2103        });
2104    }
2105    Ok(crate::physical::CollectionContract {
2106        name: query.name.clone(),
2107        declared_model: crate::catalog::CollectionModel::Table,
2108        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2109        origin: crate::physical::ContractOrigin::Explicit,
2110        version: 1,
2111        created_at_unix_ms: now,
2112        updated_at_unix_ms: now,
2113        default_ttl_ms: query.default_ttl_ms,
2114        vector_dimension: None,
2115        vector_metric: None,
2116        context_index_fields: query.context_index_fields.clone(),
2117        declared_columns,
2118        table_def: Some(build_table_def_from_create_table(query)?),
2119        timestamps_enabled: query.timestamps,
2120        context_index_enabled: query.context_index_enabled
2121            || !query.context_index_fields.is_empty(),
2122        metrics_raw_retention_ms: None,
2123        metrics_rollup_policies: Vec::new(),
2124        metrics_tenant_identity: None,
2125        metrics_namespace: None,
2126        append_only: query.append_only,
2127        subscriptions: query.subscriptions.clone(),
2128        analytics_config: Vec::new(),
2129        session_key: None,
2130        session_gap_ms: None,
2131        retention_duration_ms: None,
2132        analytical_storage: None,
2133        ai_policy: query.ai_policy.clone(),
2134    })
2135}
2136
2137fn default_collection_contract_for_existing_table(
2138    name: &str,
2139) -> crate::physical::CollectionContract {
2140    let now = current_unix_ms();
2141    crate::physical::CollectionContract {
2142        name: name.to_string(),
2143        declared_model: crate::catalog::CollectionModel::Table,
2144        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2145        origin: crate::physical::ContractOrigin::Explicit,
2146        version: 0,
2147        created_at_unix_ms: now,
2148        updated_at_unix_ms: now,
2149        default_ttl_ms: None,
2150        vector_dimension: None,
2151        vector_metric: None,
2152        context_index_fields: Vec::new(),
2153        declared_columns: Vec::new(),
2154        table_def: Some(reddb_types::TableDef::new(name.to_string())),
2155        timestamps_enabled: false,
2156        context_index_enabled: false,
2157        metrics_raw_retention_ms: None,
2158        metrics_rollup_policies: Vec::new(),
2159        metrics_tenant_identity: None,
2160        metrics_namespace: None,
2161        append_only: false,
2162        subscriptions: Vec::new(),
2163        analytics_config: Vec::new(),
2164        session_key: None,
2165        session_gap_ms: None,
2166        retention_duration_ms: None,
2167        analytical_storage: None,
2168
2169        ai_policy: None,
2170    }
2171}
2172
2173fn keyed_collection_contract(
2174    name: &str,
2175    model: crate::catalog::CollectionModel,
2176    analytics_config: Vec<crate::catalog::AnalyticsViewDescriptor>,
2177) -> crate::physical::CollectionContract {
2178    let now = current_unix_ms();
2179    crate::physical::CollectionContract {
2180        name: name.to_string(),
2181        declared_model: model,
2182        schema_mode: crate::catalog::SchemaMode::Dynamic,
2183        origin: crate::physical::ContractOrigin::Explicit,
2184        version: 1,
2185        created_at_unix_ms: now,
2186        updated_at_unix_ms: now,
2187        default_ttl_ms: None,
2188        vector_dimension: None,
2189        vector_metric: None,
2190        context_index_fields: Vec::new(),
2191        declared_columns: Vec::new(),
2192        table_def: None,
2193        timestamps_enabled: false,
2194        context_index_enabled: false,
2195        metrics_raw_retention_ms: None,
2196        metrics_rollup_policies: Vec::new(),
2197        metrics_tenant_identity: None,
2198        metrics_namespace: None,
2199        append_only: false,
2200        subscriptions: Vec::new(),
2201        analytics_config,
2202        session_key: None,
2203        session_gap_ms: None,
2204        retention_duration_ms: None,
2205        analytical_storage: None,
2206
2207        ai_policy: None,
2208    }
2209}
2210
2211fn metrics_collection_contract(query: &CreateTableQuery) -> crate::physical::CollectionContract {
2212    let now = current_unix_ms();
2213    crate::physical::CollectionContract {
2214        name: query.name.clone(),
2215        declared_model: crate::catalog::CollectionModel::Metrics,
2216        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2217        origin: crate::physical::ContractOrigin::Explicit,
2218        version: 1,
2219        created_at_unix_ms: now,
2220        updated_at_unix_ms: now,
2221        default_ttl_ms: query.default_ttl_ms,
2222        vector_dimension: None,
2223        vector_metric: None,
2224        context_index_fields: Vec::new(),
2225        declared_columns: Vec::new(),
2226        table_def: None,
2227        timestamps_enabled: false,
2228        context_index_enabled: false,
2229        metrics_raw_retention_ms: query.default_ttl_ms,
2230        metrics_rollup_policies: query.metrics_rollup_policies.clone(),
2231        metrics_tenant_identity: Some(
2232            query
2233                .tenant_by
2234                .clone()
2235                .unwrap_or_else(|| "current_tenant".to_string()),
2236        ),
2237        metrics_namespace: Some("default".to_string()),
2238        append_only: true,
2239        subscriptions: Vec::new(),
2240        analytics_config: Vec::new(),
2241        session_key: None,
2242        session_gap_ms: None,
2243        retention_duration_ms: None,
2244        analytical_storage: None,
2245
2246        ai_policy: None,
2247    }
2248}
2249
2250fn vector_collection_contract(query: &CreateVectorQuery) -> crate::physical::CollectionContract {
2251    let now = current_unix_ms();
2252    crate::physical::CollectionContract {
2253        name: query.name.clone(),
2254        declared_model: crate::catalog::CollectionModel::Vector,
2255        schema_mode: crate::catalog::SchemaMode::Dynamic,
2256        origin: crate::physical::ContractOrigin::Explicit,
2257        version: 1,
2258        created_at_unix_ms: now,
2259        updated_at_unix_ms: now,
2260        default_ttl_ms: None,
2261        vector_dimension: Some(query.dimension),
2262        vector_metric: Some(query.metric),
2263        context_index_fields: Vec::new(),
2264        declared_columns: Vec::new(),
2265        table_def: None,
2266        timestamps_enabled: false,
2267        context_index_enabled: false,
2268        metrics_raw_retention_ms: None,
2269        metrics_rollup_policies: Vec::new(),
2270        metrics_tenant_identity: None,
2271        metrics_namespace: None,
2272        append_only: false,
2273        subscriptions: Vec::new(),
2274        analytics_config: Vec::new(),
2275        session_key: None,
2276        session_gap_ms: None,
2277        retention_duration_ms: None,
2278        analytical_storage: None,
2279
2280        ai_policy: None,
2281    }
2282}
2283
2284fn declared_column_contract_from_ddl(
2285    column: &CreateColumnDef,
2286) -> crate::physical::DeclaredColumnContract {
2287    crate::physical::DeclaredColumnContract {
2288        name: column.name.clone(),
2289        data_type: column.data_type.clone(),
2290        sql_type: Some(column.sql_type.clone()),
2291        not_null: column.not_null,
2292        default: column.default.clone(),
2293        generated: column.generated.clone(),
2294        check: column.check.clone(),
2295        compress: column.compress,
2296        unique: column.unique,
2297        primary_key: column.primary_key,
2298        enum_variants: column.enum_variants.clone(),
2299        array_element: column.array_element.clone(),
2300        decimal_precision: column.decimal_precision,
2301    }
2302}
2303
2304fn apply_alter_operations_to_contract(
2305    contract: &mut crate::physical::CollectionContract,
2306    operations: &[AlterOperation],
2307) {
2308    if contract.table_def.is_none() {
2309        contract.table_def = Some(reddb_types::TableDef::new(contract.name.clone()));
2310    }
2311    for operation in operations {
2312        match operation {
2313            AlterOperation::AddColumn(column) => {
2314                if !contract
2315                    .declared_columns
2316                    .iter()
2317                    .any(|existing| existing.name == column.name)
2318                {
2319                    contract
2320                        .declared_columns
2321                        .push(declared_column_contract_from_ddl(column));
2322                }
2323                if let Some(table_def) = contract.table_def.as_mut() {
2324                    if table_def.get_column(&column.name).is_none() {
2325                        if let Ok(column_def) = column_def_from_ddl(column) {
2326                            if column.primary_key {
2327                                table_def.primary_key.push(column.name.clone());
2328                                table_def.constraints.push(
2329                                    reddb_types::Constraint::new(
2330                                        format!("pk_{}", column.name),
2331                                        reddb_types::ConstraintType::PrimaryKey,
2332                                    )
2333                                    .on_columns(vec![column.name.clone()]),
2334                                );
2335                            }
2336                            if column.unique {
2337                                table_def.constraints.push(
2338                                    reddb_types::Constraint::new(
2339                                        format!("uniq_{}", column.name),
2340                                        reddb_types::ConstraintType::Unique,
2341                                    )
2342                                    .on_columns(vec![column.name.clone()]),
2343                                );
2344                            }
2345                            if column.not_null {
2346                                table_def.constraints.push(
2347                                    reddb_types::Constraint::new(
2348                                        format!("not_null_{}", column.name),
2349                                        reddb_types::ConstraintType::NotNull,
2350                                    )
2351                                    .on_columns(vec![column.name.clone()]),
2352                                );
2353                            }
2354                            table_def.columns.push(column_def);
2355                        }
2356                    }
2357                }
2358            }
2359            AlterOperation::DropColumn(name) => {
2360                contract
2361                    .declared_columns
2362                    .retain(|column| column.name != *name);
2363                if let Some(table_def) = contract.table_def.as_mut() {
2364                    if let Some(index) = table_def.column_index(name) {
2365                        table_def.columns.remove(index);
2366                    }
2367                    table_def.primary_key.retain(|column| column != name);
2368                    table_def.constraints.retain(|constraint| {
2369                        !constraint.columns.iter().any(|column| column == name)
2370                    });
2371                    table_def
2372                        .indexes
2373                        .retain(|index| !index.columns.iter().any(|column| column == name));
2374                }
2375            }
2376            AlterOperation::RenameColumn { from, to } => {
2377                if contract
2378                    .declared_columns
2379                    .iter()
2380                    .any(|column| column.name == *to)
2381                {
2382                    continue;
2383                }
2384                if let Some(column) = contract
2385                    .declared_columns
2386                    .iter_mut()
2387                    .find(|column| column.name == *from)
2388                {
2389                    column.name = to.clone();
2390                }
2391                if let Some(table_def) = contract.table_def.as_mut() {
2392                    if let Some(column) = table_def
2393                        .columns
2394                        .iter_mut()
2395                        .find(|column| column.name == *from)
2396                    {
2397                        column.name = to.clone();
2398                    }
2399                    for primary_key in &mut table_def.primary_key {
2400                        if *primary_key == *from {
2401                            *primary_key = to.clone();
2402                        }
2403                    }
2404                    for constraint in &mut table_def.constraints {
2405                        for column in &mut constraint.columns {
2406                            if *column == *from {
2407                                *column = to.clone();
2408                            }
2409                        }
2410                        if let Some(ref_columns) = constraint.ref_columns.as_mut() {
2411                            for column in ref_columns {
2412                                if *column == *from {
2413                                    *column = to.clone();
2414                                }
2415                            }
2416                        }
2417                    }
2418                    for index in &mut table_def.indexes {
2419                        for column in &mut index.columns {
2420                            if *column == *from {
2421                                *column = to.clone();
2422                            }
2423                        }
2424                    }
2425                }
2426            }
2427            // Partition ops don't touch the column contract — metadata is
2428            // persisted separately via `red_config.partition.*`.
2429            AlterOperation::AttachPartition { .. } | AlterOperation::DetachPartition { .. } => {}
2430            // RLS toggles don't touch the column contract — flag is persisted
2431            // separately via `red_config.rls.enabled.{table}` and enforced
2432            // through the in-memory `rls_enabled_tables` set.
2433            AlterOperation::EnableRowLevelSecurity | AlterOperation::DisableRowLevelSecurity => {}
2434            // Phase 2.5.4: tenancy toggles persist via `red_config.tenant_tables.*`
2435            // and are enforced through `tenant_tables` + RLS auto-policy.
2436            AlterOperation::EnableTenancy { .. } | AlterOperation::DisableTenancy => {}
2437            AlterOperation::SetAppendOnly(on) => {
2438                contract.append_only = *on;
2439            }
2440            // VCS opt-in is persisted to red_vcs_settings by the
2441            // executor, not the contract — nothing to do here.
2442            AlterOperation::SetVersioned(_) => {}
2443            AlterOperation::EnableEvents(subscription) => {
2444                let mut subscription = subscription.clone();
2445                subscription.source = contract.name.clone();
2446                subscription.enabled = true;
2447                if let Some(existing) = contract
2448                    .subscriptions
2449                    .iter_mut()
2450                    .find(|existing| existing.target_queue == subscription.target_queue)
2451                {
2452                    *existing = subscription;
2453                } else {
2454                    contract.subscriptions.push(subscription);
2455                }
2456            }
2457            AlterOperation::DisableEvents => {
2458                for subscription in &mut contract.subscriptions {
2459                    subscription.enabled = false;
2460                }
2461            }
2462            AlterOperation::AddSubscription { name, descriptor } => {
2463                let mut sub = descriptor.clone();
2464                sub.name = name.clone();
2465                sub.source = contract.name.clone();
2466                sub.enabled = true;
2467                if let Some(existing) = contract.subscriptions.iter_mut().find(|s| s.name == *name)
2468                {
2469                    *existing = sub;
2470                } else {
2471                    contract.subscriptions.push(sub);
2472                }
2473            }
2474            AlterOperation::DropSubscription { name } => {
2475                contract.subscriptions.retain(|s| s.name != *name);
2476            }
2477            // Signer registry mutations live in `red_config` outside the
2478            // contract surface — the executor applied them directly via
2479            // `signed_writes_kind::{add,revoke}_signer`. Nothing to fold
2480            // into the column-shaped contract.
2481            AlterOperation::AddSigner { .. } | AlterOperation::RevokeSigner { .. } => {}
2482            AlterOperation::SetRetention { duration_ms } => {
2483                contract.retention_duration_ms = Some(*duration_ms);
2484            }
2485            AlterOperation::UnsetRetention => {
2486                contract.retention_duration_ms = None;
2487            }
2488            // Issue #801 — fold analytics lifecycle into the WAL-backed
2489            // `analytics_config` so the change is durable and the
2490            // `<graph>.<output>` resolver picks it up on the next read.
2491            AlterOperation::AddAnalytics(views) => {
2492                for view in views {
2493                    // Idempotent: skip outputs already enabled so a repeated
2494                    // ADD never duplicates state. The first declaration wins —
2495                    // re-declaring options requires DROP then ADD.
2496                    if !contract
2497                        .analytics_config
2498                        .iter()
2499                        .any(|existing| existing.output == view.output)
2500                    {
2501                        contract.analytics_config.push(view.clone());
2502                    }
2503                }
2504            }
2505            AlterOperation::DropAnalytics(output) => {
2506                contract
2507                    .analytics_config
2508                    .retain(|view| view.output != *output);
2509            }
2510        }
2511    }
2512}
2513
2514/// Issue #580 — returns true if the contract carries at least one
2515/// column the retention filter can use as a timestamp anchor:
2516/// either `WITH timestamps = true` (auto `created_at` / `updated_at`)
2517/// or a user-declared column with a temporal data_type.
2518pub(crate) fn retention_timestamp_column_exists(
2519    contract: &crate::physical::CollectionContract,
2520) -> bool {
2521    if contract.timestamps_enabled {
2522        return true;
2523    }
2524    if matches!(
2525        contract.declared_model,
2526        crate::catalog::CollectionModel::TimeSeries | crate::catalog::CollectionModel::Metrics
2527    ) {
2528        // Time-series and metrics collections carry an intrinsic
2529        // timestamp axis on every row even without a declared column;
2530        // retention has a natural anchor.
2531        return true;
2532    }
2533    contract
2534        .declared_columns
2535        .iter()
2536        .any(|column| is_temporal_data_type(&column.data_type))
2537}
2538
2539fn is_temporal_data_type(data_type: &str) -> bool {
2540    let upper = data_type.to_ascii_uppercase();
2541    matches!(
2542        upper.as_str(),
2543        "TIMESTAMP" | "TIMESTAMPMS" | "TIMESTAMP_MS" | "DATETIME" | "DATE"
2544    )
2545}
2546
2547fn validate_event_subscriptions(
2548    runtime: &RedDBRuntime,
2549    source: &str,
2550    subscriptions: &[crate::catalog::SubscriptionDescriptor],
2551) -> RedDBResult<()> {
2552    for subscription in subscriptions
2553        .iter()
2554        .filter(|subscription| subscription.enabled)
2555    {
2556        if subscription.all_tenants && crate::runtime::impl_core::current_tenant().is_some() {
2557            return Err(RedDBError::Query(
2558                "cross-tenant subscription requires cluster-admin capability (events:cluster_subscribe)".to_string(),
2559            ));
2560        }
2561        validate_subscription_auth(runtime, source, subscription)?;
2562        if subscription.target_queue == source
2563            || subscription_would_create_cycle(
2564                &runtime.inner.db,
2565                source,
2566                &subscription.target_queue,
2567            )
2568        {
2569            return Err(RedDBError::Query(
2570                "subscription would create cycle".to_string(),
2571            ));
2572        }
2573        audit_subscription_redact_gap(runtime, source, subscription);
2574    }
2575    Ok(())
2576}
2577
2578fn validate_subscription_auth(
2579    runtime: &RedDBRuntime,
2580    source: &str,
2581    subscription: &crate::catalog::SubscriptionDescriptor,
2582) -> RedDBResult<()> {
2583    let auth_store = match runtime.inner.auth_store.read().clone() {
2584        Some(store) => store,
2585        None => return Ok(()),
2586    };
2587    let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2588        Some(identity) => identity,
2589        None => return Ok(()),
2590    };
2591    let tenant = crate::runtime::impl_core::current_tenant();
2592    let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2593
2594    if auth_store.iam_authorization_enabled() {
2595        let ctx = crate::auth::policies::EvalContext {
2596            principal_tenant: tenant.clone(),
2597            current_tenant: tenant.clone(),
2598            peer_ip: None,
2599            mfa_present: false,
2600            now_ms: crate::auth::now_ms(),
2601            principal_is_admin_role: role == crate::auth::Role::Admin,
2602            principal_is_platform_scoped: principal.tenant.is_none(),
2603        };
2604        let mut source_resource = crate::auth::policies::ResourceRef::new("table", source);
2605        if let Some(t) = tenant.as_deref() {
2606            source_resource = source_resource.with_tenant(t.to_string());
2607        }
2608        if !auth_store.check_policy_authz_with_role(
2609            &principal,
2610            "select",
2611            &source_resource,
2612            &ctx,
2613            role,
2614        ) {
2615            return Err(RedDBError::Query(format!(
2616                "permission denied: principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
2617                principal, source_resource.kind, source_resource.name
2618            )));
2619        }
2620
2621        let mut target_resource =
2622            crate::auth::policies::ResourceRef::new("queue", subscription.target_queue.clone());
2623        if let Some(t) = tenant.as_deref() {
2624            target_resource = target_resource.with_tenant(t.to_string());
2625        }
2626        if !auth_store.check_policy_authz_with_role(
2627            &principal,
2628            "write",
2629            &target_resource,
2630            &ctx,
2631            role,
2632        ) {
2633            return Err(RedDBError::Query(format!(
2634                "permission denied: principal=`{}` action=`write` resource=`{}:{}` denied by IAM policy",
2635                principal, target_resource.kind, target_resource.name
2636            )));
2637        }
2638        return Ok(());
2639    }
2640
2641    let ctx = crate::auth::privileges::AuthzContext {
2642        principal: &username,
2643        effective_role: role,
2644        tenant: tenant.as_deref(),
2645    };
2646    auth_store
2647        .check_grant(
2648            &ctx,
2649            crate::auth::privileges::Action::Select,
2650            &crate::auth::privileges::Resource::table_from_name(source),
2651        )
2652        .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2653    auth_store
2654        .check_grant(
2655            &ctx,
2656            crate::auth::privileges::Action::Insert,
2657            &crate::auth::privileges::Resource::table_from_name(&subscription.target_queue),
2658        )
2659        .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2660    Ok(())
2661}
2662
2663fn audit_subscription_redact_gap(
2664    runtime: &RedDBRuntime,
2665    source: &str,
2666    subscription: &crate::catalog::SubscriptionDescriptor,
2667) {
2668    let auth_store = match runtime.inner.auth_store.read().clone() {
2669        Some(store) if store.iam_authorization_enabled() => store,
2670        _ => return,
2671    };
2672    let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2673        Some(identity) => identity,
2674        None => return,
2675    };
2676    let tenant = crate::runtime::impl_core::current_tenant();
2677    let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2678    let missing = subscription_redact_gap_columns(&auth_store, &principal, source, subscription);
2679    if missing.is_empty() {
2680        return;
2681    }
2682
2683    let columns = missing.into_iter().collect::<Vec<_>>().join(", ");
2684    tracing::warn!(
2685        target: "reddb::operator",
2686        "subscription_redact_gap: source={} target_queue={} columns=[{}]",
2687        source,
2688        subscription.target_queue,
2689        columns
2690    );
2691    let mut event = AuditEvent::builder("subscription_redact_gap")
2692        .principal(username)
2693        .source(AuditAuthSource::System)
2694        .resource(format!(
2695            "subscription:{}->{}",
2696            source, subscription.target_queue
2697        ))
2698        .outcome(Outcome::Success)
2699        .field(AuditFieldEscaper::field("source", source))
2700        .field(AuditFieldEscaper::field(
2701            "target_queue",
2702            subscription.target_queue.clone(),
2703        ))
2704        .field(AuditFieldEscaper::field(
2705            "subscription",
2706            subscription.name.clone(),
2707        ))
2708        .field(AuditFieldEscaper::field("columns", columns))
2709        .field(AuditFieldEscaper::field("role", role.as_str()));
2710    if let Some(t) = tenant {
2711        event = event.tenant(t);
2712    }
2713    runtime.inner.audit_log.record_event(event.build());
2714}
2715
2716fn subscription_redact_gap_columns(
2717    auth_store: &crate::auth::store::AuthStore,
2718    principal: &crate::auth::UserId,
2719    source: &str,
2720    subscription: &crate::catalog::SubscriptionDescriptor,
2721) -> BTreeSet<String> {
2722    let redacted: HashSet<String> = subscription
2723        .redact_fields
2724        .iter()
2725        .map(|field| field.to_ascii_lowercase())
2726        .collect();
2727    auth_store
2728        .effective_policies(principal)
2729        .iter()
2730        .flat_map(|policy| policy.statements.iter())
2731        .filter(|statement| statement.effect == crate::auth::policies::Effect::Deny)
2732        .filter(|statement| statement.actions.iter().any(action_pattern_matches_select))
2733        .flat_map(|statement| statement.resources.iter())
2734        .filter_map(|resource| denied_column_for_source(resource, source))
2735        .filter(|column| !redact_covers_column(&redacted, source, column))
2736        .collect()
2737}
2738
2739fn action_pattern_matches_select(pattern: &crate::auth::policies::ActionPattern) -> bool {
2740    match pattern {
2741        crate::auth::policies::ActionPattern::Wildcard => true,
2742        crate::auth::policies::ActionPattern::Exact(action) => action == "select",
2743        crate::auth::policies::ActionPattern::Prefix(prefix) => {
2744            "select".len() > prefix.len() + 1
2745                && "select".starts_with(prefix)
2746                && "select".as_bytes()[prefix.len()] == b':'
2747        }
2748    }
2749}
2750
2751fn denied_column_for_source(
2752    resource: &crate::auth::policies::ResourcePattern,
2753    source: &str,
2754) -> Option<String> {
2755    let crate::auth::policies::ResourcePattern::Exact { kind, name } = resource else {
2756        return None;
2757    };
2758    if kind != "column" {
2759        return None;
2760    }
2761    let column = crate::auth::ColumnRef::parse_resource_name(name).ok()?;
2762    (column.table_resource_name() == source).then_some(column.column)
2763}
2764
2765fn redact_covers_column(redacted: &HashSet<String>, source: &str, column: &str) -> bool {
2766    let column = column.to_ascii_lowercase();
2767    let qualified = format!("{}.{}", source.to_ascii_lowercase(), column);
2768    redacted.contains("*") || redacted.contains(&column) || redacted.contains(&qualified)
2769}
2770
2771fn subscription_would_create_cycle(
2772    db: &crate::storage::unified::devx::RedDB,
2773    source: &str,
2774    target: &str,
2775) -> bool {
2776    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
2777    for contract in db.collection_contracts() {
2778        for subscription in contract
2779            .subscriptions
2780            .into_iter()
2781            .filter(|subscription| subscription.enabled)
2782        {
2783            graph
2784                .entry(subscription.source)
2785                .or_default()
2786                .push(subscription.target_queue);
2787        }
2788    }
2789    graph
2790        .entry(source.to_string())
2791        .or_default()
2792        .push(target.to_string());
2793
2794    let mut stack = vec![target.to_string()];
2795    let mut seen = HashSet::new();
2796    while let Some(node) = stack.pop() {
2797        if node == source {
2798            return true;
2799        }
2800        if !seen.insert(node.clone()) {
2801            continue;
2802        }
2803        if let Some(next) = graph.get(&node) {
2804            stack.extend(next.iter().cloned());
2805        }
2806    }
2807    false
2808}
2809
2810pub(crate) fn ensure_event_target_queue_pub(
2811    runtime: &RedDBRuntime,
2812    queue: &str,
2813) -> RedDBResult<()> {
2814    ensure_event_target_queue(runtime, queue)
2815}
2816
2817fn ensure_event_target_queue(runtime: &RedDBRuntime, queue: &str) -> RedDBResult<()> {
2818    let store = runtime.inner.db.store();
2819    if store.get_collection(queue).is_some() {
2820        return Ok(());
2821    }
2822    store
2823        .create_collection(queue)
2824        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2825    runtime
2826        .inner
2827        .db
2828        .save_collection_contract(event_queue_collection_contract(queue))
2829        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2830    store.set_config_tree(
2831        &format!("queue.{queue}.mode"),
2832        &crate::serde_json::Value::String("fanout".to_string()),
2833    );
2834    Ok(())
2835}
2836
2837fn event_queue_collection_contract(queue: &str) -> crate::physical::CollectionContract {
2838    let now = current_unix_ms();
2839    crate::physical::CollectionContract {
2840        name: queue.to_string(),
2841        declared_model: crate::catalog::CollectionModel::Queue,
2842        schema_mode: crate::catalog::SchemaMode::Dynamic,
2843        origin: crate::physical::ContractOrigin::Implicit,
2844        version: 1,
2845        created_at_unix_ms: now,
2846        updated_at_unix_ms: now,
2847        default_ttl_ms: None,
2848        vector_dimension: None,
2849        vector_metric: None,
2850        context_index_fields: Vec::new(),
2851        declared_columns: Vec::new(),
2852        table_def: None,
2853        timestamps_enabled: false,
2854        context_index_enabled: false,
2855        metrics_raw_retention_ms: None,
2856        metrics_rollup_policies: Vec::new(),
2857        metrics_tenant_identity: None,
2858        metrics_namespace: None,
2859        append_only: true,
2860        subscriptions: Vec::new(),
2861        analytics_config: Vec::new(),
2862        session_key: None,
2863        session_gap_ms: None,
2864        retention_duration_ms: None,
2865        analytical_storage: None,
2866
2867        ai_policy: None,
2868    }
2869}
2870
2871fn build_table_def_from_create_table(
2872    query: &CreateTableQuery,
2873) -> RedDBResult<reddb_types::TableDef> {
2874    let mut table = reddb_types::TableDef::new(query.name.clone());
2875    for column in &query.columns {
2876        if column.primary_key {
2877            table.primary_key.push(column.name.clone());
2878            table.constraints.push(
2879                reddb_types::Constraint::new(
2880                    format!("pk_{}", column.name),
2881                    reddb_types::ConstraintType::PrimaryKey,
2882                )
2883                .on_columns(vec![column.name.clone()]),
2884            );
2885        }
2886        if column.unique {
2887            table.constraints.push(
2888                reddb_types::Constraint::new(
2889                    format!("uniq_{}", column.name),
2890                    reddb_types::ConstraintType::Unique,
2891                )
2892                .on_columns(vec![column.name.clone()]),
2893            );
2894        }
2895        if column.not_null {
2896            table.constraints.push(
2897                reddb_types::Constraint::new(
2898                    format!("not_null_{}", column.name),
2899                    reddb_types::ConstraintType::NotNull,
2900                )
2901                .on_columns(vec![column.name.clone()]),
2902            );
2903        }
2904        table.columns.push(column_def_from_ddl(column)?);
2905    }
2906    table.constraints.extend(
2907        analyze_create_table(query)
2908            .map_err(|err| RedDBError::Query(err.to_string()))?
2909            .unique_constraints,
2910    );
2911    // WITH timestamps = true: append the two runtime-managed columns
2912    // to the schema so resolved_contract_columns exposes them to the
2913    // normalize/validate path. Declared as UnsignedInteger (unix-ms),
2914    // not-nullable; the write path auto-fills them.
2915    if query.timestamps {
2916        table.columns.push(
2917            reddb_types::ColumnDef::new(
2918                "created_at".to_string(),
2919                reddb_types::DataType::UnsignedInteger,
2920            )
2921            .not_null(),
2922        );
2923        table.columns.push(
2924            reddb_types::ColumnDef::new(
2925                "updated_at".to_string(),
2926                reddb_types::DataType::UnsignedInteger,
2927            )
2928            .not_null(),
2929        );
2930        table.constraints.push(
2931            reddb_types::Constraint::new(
2932                "not_null_created_at".to_string(),
2933                reddb_types::ConstraintType::NotNull,
2934            )
2935            .on_columns(vec!["created_at".to_string()]),
2936        );
2937        table.constraints.push(
2938            reddb_types::Constraint::new(
2939                "not_null_updated_at".to_string(),
2940                reddb_types::ConstraintType::NotNull,
2941            )
2942            .on_columns(vec!["updated_at".to_string()]),
2943        );
2944    }
2945    table
2946        .validate()
2947        .map_err(|err| RedDBError::Query(format!("invalid table definition: {err}")))?;
2948    Ok(table)
2949}
2950
2951fn column_def_from_ddl(column: &CreateColumnDef) -> RedDBResult<reddb_types::ColumnDef> {
2952    let data_type = resolve_declared_data_type(&column.data_type)
2953        .map_err(|err| RedDBError::Query(err.to_string()))?;
2954    let mut column_def = reddb_types::ColumnDef::new(column.name.clone(), data_type);
2955    if column.not_null {
2956        column_def = column_def.not_null();
2957    }
2958    if let Some(default) = &column.default {
2959        column_def = column_def.with_default(default.as_bytes().to_vec());
2960    }
2961    if column.compress.unwrap_or(0) > 0 {
2962        column_def = column_def.compressed();
2963    }
2964    if !column.enum_variants.is_empty() {
2965        column_def = column_def.with_variants(column.enum_variants.clone());
2966    }
2967    if let Some(precision) = column.decimal_precision {
2968        column_def = column_def.with_precision(precision);
2969    }
2970    if let Some(element_type) = &column.array_element {
2971        column_def = column_def.with_element_type(
2972            resolve_declared_data_type(element_type)
2973                .map_err(|err| RedDBError::Query(err.to_string()))?,
2974        );
2975    }
2976    column_def = column_def.with_metadata("ddl_data_type", column.data_type.clone());
2977    if column.unique {
2978        column_def = column_def.with_metadata("unique", "true");
2979    }
2980    if column.primary_key {
2981        column_def = column_def.with_metadata("primary_key", "true");
2982    }
2983    Ok(column_def)
2984}
2985
2986fn current_unix_ms() -> u128 {
2987    std::time::SystemTime::now()
2988        .duration_since(std::time::UNIX_EPOCH)
2989        .unwrap_or_default()
2990        .as_millis()
2991}
2992
2993#[cfg(test)]
2994mod tests {
2995    use crate::auth::policies::{ActionPattern, Effect, Policy, ResourcePattern, Statement};
2996    use crate::auth::store::{AuthStore, PrincipalRef};
2997    use crate::auth::UserId;
2998    use crate::auth::{AuthConfig, Role};
2999    use crate::runtime::impl_core::{clear_current_auth_identity, set_current_auth_identity};
3000    use crate::{RedDBOptions, RedDBRuntime};
3001    use reddb_types::Value;
3002    use std::sync::Arc;
3003
3004    fn make_allow_policy(id: &str, action: &str, collection: &str) -> Policy {
3005        Policy {
3006            id: id.to_string(),
3007            version: 1,
3008            tenant: None,
3009            created_at: 0,
3010            updated_at: 0,
3011            statements: vec![Statement {
3012                sid: None,
3013                effect: Effect::Allow,
3014                actions: vec![ActionPattern::Exact(action.to_string())],
3015                resources: vec![ResourcePattern::Exact {
3016                    kind: "collection".to_string(),
3017                    name: collection.to_string(),
3018                }],
3019                condition: None,
3020            }],
3021        }
3022    }
3023
3024    fn wire_auth_store(rt: &RedDBRuntime) -> Arc<AuthStore> {
3025        let store = Arc::new(AuthStore::new(AuthConfig::default()));
3026        *rt.inner.auth_store.write() = Some(store.clone());
3027        store
3028    }
3029
3030    #[test]
3031    fn drop_denied_without_iam_policy() {
3032        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3033        rt.execute_query("CREATE TABLE foo (id INT)").unwrap();
3034        let store = wire_auth_store(&rt);
3035        // Put a select-only policy so IAM mode activates, but give alice no drop policy.
3036        let select_only = Policy {
3037            id: "select-only".to_string(),
3038            version: 1,
3039            tenant: None,
3040            created_at: 0,
3041            updated_at: 0,
3042            statements: vec![Statement {
3043                sid: None,
3044                effect: Effect::Allow,
3045                actions: vec![ActionPattern::Exact("select".to_string())],
3046                resources: vec![ResourcePattern::Wildcard],
3047                condition: None,
3048            }],
3049        };
3050        store.put_policy_internal(select_only).unwrap();
3051        let alice = UserId::from_parts(None, "alice");
3052        store
3053            .attach_policy(PrincipalRef::User(alice), "select-only")
3054            .unwrap();
3055        set_current_auth_identity("alice".to_string(), Role::Write);
3056        let err = rt.execute_query("DROP TABLE foo").unwrap_err();
3057        clear_current_auth_identity();
3058        assert!(
3059            format!("{err}").contains("denied by IAM policy"),
3060            "got: {err}"
3061        );
3062    }
3063
3064    #[test]
3065    fn drop_allowed_with_explicit_iam_policy() {
3066        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3067        rt.execute_query("CREATE TABLE bar (id INT)").unwrap();
3068        let store = wire_auth_store(&rt);
3069        let policy = make_allow_policy("allow-drop-bar", "drop", "bar");
3070        store.put_policy_internal(policy).unwrap();
3071        let bob = UserId::from_parts(None, "bob");
3072        store
3073            .attach_policy(PrincipalRef::User(bob), "allow-drop-bar")
3074            .unwrap();
3075        set_current_auth_identity("bob".to_string(), Role::Write);
3076        rt.execute_query("DROP TABLE bar").unwrap();
3077        clear_current_auth_identity();
3078    }
3079
3080    #[test]
3081    fn drop_allowed_with_wildcard_iam_policy() {
3082        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3083        rt.execute_query("CREATE TABLE baz (id INT)").unwrap();
3084        let store = wire_auth_store(&rt);
3085        let policy = Policy {
3086            id: "allow-drop-all".to_string(),
3087            version: 1,
3088            tenant: None,
3089            created_at: 0,
3090            updated_at: 0,
3091            statements: vec![Statement {
3092                sid: None,
3093                effect: Effect::Allow,
3094                actions: vec![ActionPattern::Exact("drop".to_string())],
3095                resources: vec![ResourcePattern::Wildcard],
3096                condition: None,
3097            }],
3098        };
3099        store.put_policy_internal(policy).unwrap();
3100        let carl = UserId::from_parts(None, "carl");
3101        store
3102            .attach_policy(PrincipalRef::User(carl), "allow-drop-all")
3103            .unwrap();
3104        set_current_auth_identity("carl".to_string(), Role::Write);
3105        rt.execute_query("DROP TABLE baz").unwrap();
3106        clear_current_auth_identity();
3107    }
3108
3109    #[test]
3110    fn truncate_denied_without_iam_policy() {
3111        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3112        rt.execute_query("CREATE TABLE qux (id INT)").unwrap();
3113        let store = wire_auth_store(&rt);
3114        // Acceptance #2 (#712 / S5A): in `policy_only` mode a
3115        // principal with no matching policy is denied even if their
3116        // role would have permitted the action. The pre-#712 default
3117        // of "no policy → deny" only holds under `policy_only`, so
3118        // pin the mode explicitly here so the assertion holds
3119        // regardless of the construction-time default.
3120        store
3121            .set_enforcement_mode(crate::auth::enforcement_mode::PolicyEnforcementMode::PolicyOnly);
3122        // A policy exists (IAM active) but gives no truncate right.
3123        let select_only = Policy {
3124            id: "select-only-2".to_string(),
3125            version: 1,
3126            tenant: None,
3127            created_at: 0,
3128            updated_at: 0,
3129            statements: vec![Statement {
3130                sid: None,
3131                effect: Effect::Allow,
3132                actions: vec![ActionPattern::Exact("select".to_string())],
3133                resources: vec![ResourcePattern::Wildcard],
3134                condition: None,
3135            }],
3136        };
3137        store.put_policy_internal(select_only).unwrap();
3138        let dana = UserId::from_parts(None, "dana");
3139        store
3140            .attach_policy(PrincipalRef::User(dana), "select-only-2")
3141            .unwrap();
3142        set_current_auth_identity("dana".to_string(), Role::Write);
3143        let err = rt.execute_query("TRUNCATE TABLE qux").unwrap_err();
3144        clear_current_auth_identity();
3145        assert!(
3146            format!("{err}").contains("denied by IAM policy"),
3147            "got: {err}"
3148        );
3149    }
3150
3151    #[test]
3152    fn truncate_table_clears_rows_and_preserves_schema_and_indexes() {
3153        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3154        rt.execute_query("CREATE TABLE users (id INT, name TEXT)")
3155            .unwrap();
3156        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'ana'), (2, 'bob')")
3157            .unwrap();
3158        rt.execute_query("CREATE INDEX idx_users_id ON users (id) USING HASH")
3159            .unwrap();
3160
3161        let truncated = rt.execute_query("TRUNCATE TABLE users").unwrap();
3162        assert_eq!(truncated.statement_type, "truncate");
3163        assert_eq!(truncated.affected_rows, 0);
3164
3165        let empty = rt.execute_query("SELECT id FROM users").unwrap();
3166        assert!(empty.result.records.is_empty());
3167
3168        rt.execute_query("INSERT INTO users (id, name) VALUES (3, 'cy')")
3169            .unwrap();
3170        let selected = rt
3171            .execute_query("SELECT name FROM users WHERE id = 3")
3172            .unwrap();
3173        let name = selected.result.records[0].get("name").unwrap();
3174        assert_eq!(name, &Value::text("cy"));
3175        assert!(rt.db().collection_contract("users").is_some());
3176        assert!(rt
3177            .inner
3178            .index_store
3179            .list_indices("users")
3180            .iter()
3181            .any(|index| index.name == "idx_users_id"));
3182    }
3183
3184    #[test]
3185    fn truncate_collection_is_polymorphic_and_typed_mismatch_fails() {
3186        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3187        rt.execute_query("CREATE QUEUE tasks").unwrap();
3188        rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3189
3190        let err = rt.execute_query("TRUNCATE TABLE tasks").unwrap_err();
3191        assert!(format!("{err}").contains("model mismatch: expected table, got queue"));
3192
3193        rt.execute_query("TRUNCATE COLLECTION tasks").unwrap();
3194        let len = rt.execute_query("QUEUE LEN tasks").unwrap();
3195        assert_eq!(
3196            len.result.records[0].get("len"),
3197            Some(&Value::UnsignedInteger(0))
3198        );
3199    }
3200
3201    #[test]
3202    fn truncate_system_schema_is_read_only() {
3203        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3204        let err = rt
3205            .execute_query("TRUNCATE COLLECTION red.collections")
3206            .unwrap_err();
3207        assert!(format!("{err}").contains("system schema is read-only"));
3208    }
3209
3210    // ── #302 / #310: TRUNCATE / DROP single-event semantics ────────────────
3211
3212    fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3213        let result = rt
3214            .execute_query(&format!("QUEUE PEEK {queue} 100"))
3215            .expect("peek queue");
3216        result
3217            .result
3218            .records
3219            .iter()
3220            .map(
3221                |record| match record.get("payload").expect("payload column") {
3222                    Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3223                    other => panic!("expected JSON queue payload, got {other:?}"),
3224                },
3225            )
3226            .collect()
3227    }
3228
3229    /// `TRUNCATE users` on an event-enabled collection emits exactly 1
3230    /// `truncate` event, not one delete event per row.
3231    #[test]
3232    fn truncate_event_enabled_table_emits_single_truncate_event() {
3233        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3234        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3235            .unwrap();
3236        rt.execute_query(
3237            "INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')",
3238        )
3239        .unwrap();
3240
3241        // Drain the 3 insert events so we start clean.
3242        rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3243
3244        rt.execute_query("TRUNCATE TABLE users").unwrap();
3245
3246        let events = queue_payloads(&rt, "users_events");
3247        // Must be exactly 1 truncate event, not 3 delete events.
3248        assert_eq!(
3249            events.len(),
3250            1,
3251            "expected 1 truncate event, got {}",
3252            events.len()
3253        );
3254        let ev = events[0].as_object().expect("event is object");
3255        assert_eq!(
3256            ev.get("op").and_then(crate::json::Value::as_str),
3257            Some("truncate")
3258        );
3259        assert_eq!(
3260            ev.get("collection").and_then(crate::json::Value::as_str),
3261            Some("users")
3262        );
3263        assert_eq!(
3264            ev.get("entities_count")
3265                .and_then(crate::json::Value::as_u64),
3266            Some(3)
3267        );
3268        assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3269        assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3270        assert!(ev
3271            .get("event_id")
3272            .and_then(crate::json::Value::as_str)
3273            .is_some_and(|s| !s.is_empty()));
3274    }
3275
3276    /// `TRUNCATE users` on a collection without event subscription emits no events.
3277    #[test]
3278    fn truncate_no_events_collection_emits_nothing() {
3279        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3280        rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3281            .unwrap();
3282        rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a'), (2, 'b')")
3283            .unwrap();
3284        // No EVENTS subscription — truncate must work without touching any queue.
3285        rt.execute_query("TRUNCATE TABLE plain").unwrap();
3286        // No crash, no queue to check. Just verify truncation happened.
3287        let rows = rt.execute_query("SELECT id FROM plain").unwrap();
3288        assert!(rows.result.records.is_empty());
3289    }
3290
3291    /// `DROP TABLE users` on an event-enabled collection emits exactly 1
3292    /// `collection_dropped` event. The subscription is removed from the
3293    /// source contract but the target queue is preserved for consumer drain.
3294    #[test]
3295    fn drop_event_enabled_table_emits_single_collection_dropped_event() {
3296        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3297        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3298            .unwrap();
3299        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob')")
3300            .unwrap();
3301
3302        // Drain insert events so we start clean.
3303        rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3304
3305        rt.execute_query("DROP TABLE users").unwrap();
3306
3307        // Queue must still exist with 1 collection_dropped event.
3308        let events = queue_payloads(&rt, "users_events");
3309        assert_eq!(
3310            events.len(),
3311            1,
3312            "expected 1 collection_dropped event, got {}",
3313            events.len()
3314        );
3315        let ev = events[0].as_object().expect("event is object");
3316        assert_eq!(
3317            ev.get("op").and_then(crate::json::Value::as_str),
3318            Some("collection_dropped")
3319        );
3320        assert_eq!(
3321            ev.get("collection").and_then(crate::json::Value::as_str),
3322            Some("users")
3323        );
3324        assert_eq!(
3325            ev.get("final_entities_count")
3326                .and_then(crate::json::Value::as_u64),
3327            Some(2)
3328        );
3329        assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3330        assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3331        assert!(ev
3332            .get("event_id")
3333            .and_then(crate::json::Value::as_str)
3334            .is_some_and(|s| !s.is_empty()));
3335
3336        // Source collection is gone.
3337        let err = rt.execute_query("SELECT id FROM users").unwrap_err();
3338        assert!(
3339            format!("{err}").contains("users"),
3340            "expected not-found error"
3341        );
3342    }
3343
3344    /// `DROP TABLE users` on a collection without event subscription works
3345    /// normally with no event emitted.
3346    #[test]
3347    fn drop_no_events_collection_emits_nothing() {
3348        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3349        rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3350            .unwrap();
3351        rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a')")
3352            .unwrap();
3353        rt.execute_query("DROP TABLE plain").unwrap();
3354        // No crash and collection is gone.
3355        let err = rt.execute_query("SELECT id FROM plain").unwrap_err();
3356        assert!(format!("{err}").contains("plain"));
3357    }
3358
3359    // ── #297: ops_filter + WHERE filter ────────────────────────────────────
3360
3361    /// `WITH EVENTS (INSERT)` — UPDATE and DELETE events must NOT be emitted.
3362    #[test]
3363    fn ops_filter_insert_only_ignores_update_and_delete() {
3364        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3365        rt.execute_query(
3366            "CREATE TABLE items (id INT, val TEXT) WITH EVENTS (INSERT) TO items_events",
3367        )
3368        .unwrap();
3369        rt.execute_query("INSERT INTO items (id, val) VALUES (1, 'a')")
3370            .unwrap();
3371        rt.execute_query("UPDATE items SET val = 'b' WHERE id = 1")
3372            .unwrap();
3373        rt.execute_query("DELETE FROM items WHERE id = 1").unwrap();
3374
3375        let events = queue_payloads(&rt, "items_events");
3376        // Only the INSERT should have fired.
3377        assert_eq!(
3378            events.len(),
3379            1,
3380            "expected 1 insert event, got {}",
3381            events.len()
3382        );
3383        assert_eq!(
3384            events[0]
3385                .as_object()
3386                .unwrap()
3387                .get("op")
3388                .and_then(crate::json::Value::as_str),
3389            Some("insert")
3390        );
3391    }
3392
3393    /// `WITH EVENTS WHERE status = 'active'` — only rows matching the predicate generate events.
3394    #[test]
3395    fn where_filter_skips_rows_that_do_not_match() {
3396        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3397        rt.execute_query(
3398            "CREATE TABLE users (id INT, status TEXT) WITH EVENTS WHERE status = 'active' TO users_events",
3399        )
3400        .unwrap();
3401
3402        // This row should generate an event.
3403        rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active')")
3404            .unwrap();
3405        // This row should NOT generate an event.
3406        rt.execute_query("INSERT INTO users (id, status) VALUES (2, 'inactive')")
3407            .unwrap();
3408
3409        let events = queue_payloads(&rt, "users_events");
3410        assert_eq!(
3411            events.len(),
3412            1,
3413            "expected 1 event (only active), got {}",
3414            events.len()
3415        );
3416        let ev = events[0].as_object().unwrap();
3417        assert_eq!(
3418            ev.get("op").and_then(crate::json::Value::as_str),
3419            Some("insert")
3420        );
3421        let after = ev.get("after").unwrap().as_object().unwrap();
3422        assert_eq!(
3423            after.get("status").and_then(crate::json::Value::as_str),
3424            Some("active")
3425        );
3426    }
3427
3428    /// `WITH EVENTS (INSERT, UPDATE) WHERE status = 'active'` — combination functional.
3429    #[test]
3430    fn ops_filter_and_where_filter_combined() {
3431        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3432        rt.execute_query(
3433            "CREATE TABLE items (id INT, status TEXT) WITH EVENTS (INSERT, UPDATE) WHERE status = 'active' TO items_events",
3434        )
3435        .unwrap();
3436
3437        // INSERT active → event
3438        rt.execute_query("INSERT INTO items (id, status) VALUES (1, 'active')")
3439            .unwrap();
3440        // INSERT inactive → no event
3441        rt.execute_query("INSERT INTO items (id, status) VALUES (2, 'inactive')")
3442            .unwrap();
3443        // UPDATE row 1 to inactive → after = inactive, no event
3444        rt.execute_query("UPDATE items SET status = 'inactive' WHERE id = 1")
3445            .unwrap();
3446        // DELETE → ops_filter excludes it
3447        rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3448
3449        let events = queue_payloads(&rt, "items_events");
3450        // Only the first INSERT (active) fires; UPDATE result is inactive so skipped; DELETE excluded by ops_filter.
3451        assert_eq!(
3452            events.len(),
3453            1,
3454            "expected 1 event, got {}: {events:?}",
3455            events.len()
3456        );
3457        assert_eq!(
3458            events[0]
3459                .as_object()
3460                .unwrap()
3461                .get("op")
3462                .and_then(crate::json::Value::as_str),
3463            Some("insert")
3464        );
3465    }
3466
3467    /// WHERE filter on DELETE events — the before-state (pre-image) is evaluated.
3468    #[test]
3469    fn where_filter_on_delete_checks_before_state() {
3470        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3471        rt.execute_query(
3472            "CREATE TABLE users (id INT, status TEXT) WITH EVENTS (DELETE) WHERE status = 'active' TO users_events",
3473        )
3474        .unwrap();
3475
3476        rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active'), (2, 'inactive')")
3477            .unwrap();
3478
3479        // Delete active row → event (before-state was active)
3480        rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3481        // Delete inactive row → no event (before-state was inactive)
3482        rt.execute_query("DELETE FROM users WHERE id = 2").unwrap();
3483
3484        let events = queue_payloads(&rt, "users_events");
3485        assert_eq!(
3486            events.len(),
3487            1,
3488            "expected 1 delete event, got {}",
3489            events.len()
3490        );
3491        let ev = events[0].as_object().unwrap();
3492        assert_eq!(
3493            ev.get("op").and_then(crate::json::Value::as_str),
3494            Some("delete")
3495        );
3496    }
3497
3498    // ── #301: schema evolution OperatorEvent on ALTER ───────────────────────
3499
3500    /// ADD COLUMN on event-enabled table must succeed (OperatorEvent is best-effort).
3501    #[test]
3502    fn alter_add_column_on_event_enabled_table_succeeds() {
3503        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3504        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3505            .unwrap();
3506        // Must not error — OperatorEvent emission is best-effort (no global sink in tests).
3507        rt.execute_query("ALTER TABLE users ADD COLUMN phone TEXT")
3508            .unwrap();
3509        // The column is now in the contract.
3510        let contract = rt.db().collection_contract("users").unwrap();
3511        assert!(
3512            contract.declared_columns.iter().any(|c| c.name == "phone"),
3513            "phone column should be in contract"
3514        );
3515        // Subscription still enabled after the alter.
3516        assert!(
3517            contract.subscriptions.iter().any(|s| s.enabled),
3518            "subscription should remain enabled"
3519        );
3520    }
3521
3522    /// DROP COLUMN on event-enabled table must succeed; non-column ALTERs
3523    /// (like ENABLE ROW LEVEL SECURITY) must also succeed without emitting.
3524    #[test]
3525    fn alter_drop_column_and_rls_on_event_enabled_table_succeeds() {
3526        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3527        rt.execute_query(
3528            "CREATE TABLE items (id INT, secret TEXT, status TEXT) WITH EVENTS TO items_events",
3529        )
3530        .unwrap();
3531        // DROP COLUMN — schema change event path exercises, must not error.
3532        rt.execute_query("ALTER TABLE items DROP COLUMN secret")
3533            .unwrap();
3534        let contract = rt.db().collection_contract("items").unwrap();
3535        assert!(
3536            !contract.declared_columns.iter().any(|c| c.name == "secret"),
3537            "secret column should be removed"
3538        );
3539        // ENABLE RLS — non-column op, no schema-change event (coverage).
3540        rt.execute_query("ALTER TABLE items ENABLE ROW LEVEL SECURITY")
3541            .unwrap();
3542        // Collection and subscription still intact.
3543        assert!(
3544            contract.subscriptions.iter().any(|s| s.enabled),
3545            "subscription should remain enabled"
3546        );
3547    }
3548
3549    /// Slice G (#675) — every newly-created `CollectionModel::Vector`
3550    /// collection is marked `vector.turbo` and gains a materialised
3551    /// `TurboCollectionState`. The marker is what the SEARCH/INSERT
3552    /// hot paths read to branch between TurboQuant and the legacy
3553    /// brute-force fallback.
3554    #[test]
3555    fn create_vector_marks_collection_as_turbo_baseline() {
3556        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3557        rt.execute_query("CREATE VECTOR embeddings DIM 4").unwrap();
3558        let store = rt.db().store();
3559        assert!(
3560            crate::runtime::vector_turbo_kind::is_turbo(&store, "embeddings"),
3561            "new vector collections must be turbo-marked baseline"
3562        );
3563        assert!(
3564            rt.db().turbo_state("embeddings").is_some(),
3565            "turbo_state must materialise after CREATE VECTOR"
3566        );
3567    }
3568
3569    /// Non-vector collections must NOT be turbo-marked. Guards against
3570    /// the always-baseline change accidentally leaking the marker onto
3571    /// CREATE TABLE / CREATE DOCUMENT / etc.
3572    #[test]
3573    fn create_table_does_not_mark_turbo() {
3574        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3575        rt.execute_query("CREATE TABLE plain (id INT)").unwrap();
3576        let store = rt.db().store();
3577        assert!(
3578            !crate::runtime::vector_turbo_kind::is_turbo(&store, "plain"),
3579            "non-vector collections must not gain the turbo marker"
3580        );
3581        assert!(rt.db().turbo_state("plain").is_none());
3582    }
3583
3584    /// `CREATE COLLECTION KIND vector.turbo` continues to mark the
3585    /// collection (idempotent with the new always-baseline path).
3586    #[test]
3587    fn create_collection_kind_vector_turbo_still_marked() {
3588        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3589        rt.execute_query("CREATE COLLECTION turbo_v KIND vector.turbo DIM 4")
3590            .unwrap();
3591        let store = rt.db().store();
3592        assert!(crate::runtime::vector_turbo_kind::is_turbo(
3593            &store, "turbo_v"
3594        ));
3595        assert!(rt.db().turbo_state("turbo_v").is_some());
3596    }
3597
3598    /// Issue #1271: a declared AI policy is persisted in the catalog and
3599    /// reads back identical via the collection contract (introspection).
3600    #[test]
3601    fn create_table_persists_and_introspects_ai_policy() {
3602        use crate::catalog::{ModerateDegradedMode, ModerateRejectAction};
3603        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3604        rt.execute_query(
3605            "CREATE TABLE posts (id INT, title TEXT, body TEXT, photo TEXT) WITH ( \
3606               EMBED (fields = ('title', 'body'), provider = 'openai', model = 'text-embedding-3-small'), \
3607               MODERATE (fields = ('body'), provider = 'openai', model = 'omni-moderation-latest', sync = true, degraded = closed, on_reject = flag), \
3608               VISION (image_field = 'photo', outputs = ('caption'), provider = 'openai', model = 'gpt-4o') \
3609             )",
3610        )
3611        .expect("create table with ai policy");
3612
3613        let contracts = rt.db().collection_contracts();
3614        let contract = contracts
3615            .iter()
3616            .find(|c| c.name == "posts")
3617            .expect("contract persisted");
3618        let policy = contract.ai_policy.as_ref().expect("ai policy persisted");
3619
3620        let embed = policy.embed.as_ref().expect("embed");
3621        assert_eq!(embed.fields, vec!["title".to_string(), "body".to_string()]);
3622        assert_eq!(embed.model, "text-embedding-3-small");
3623
3624        let moderate = policy.moderate.as_ref().expect("moderate");
3625        assert!(moderate.sync_gate);
3626        assert_eq!(moderate.degraded_mode, ModerateDegradedMode::Closed);
3627        assert_eq!(moderate.reject_action, ModerateRejectAction::Flag);
3628
3629        let vision = policy.vision.as_ref().expect("vision");
3630        assert_eq!(vision.image_field, "photo");
3631        assert_eq!(vision.model, "gpt-4o");
3632    }
3633
3634    /// Issue #1271 / #1269: a policy wiring a provider to a modality it
3635    /// cannot serve is rejected at DDL time, and the collection is not
3636    /// created. Anthropic has no embeddings product in the matrix.
3637    #[test]
3638    fn create_table_rejects_ai_policy_incapable_provider() {
3639        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3640        let err = rt
3641            .execute_query(
3642                "CREATE TABLE bad (id INT, body TEXT) WITH ( \
3643                   EMBED (fields = ('body'), provider = 'anthropic', model = 'claude-3-5-sonnet') \
3644                 )",
3645            )
3646            .unwrap_err();
3647        assert!(
3648            err.to_string()
3649                .contains("cannot serve the 'embed' modality"),
3650            "{err}"
3651        );
3652        assert!(
3653            rt.db().store().get_collection("bad").is_none(),
3654            "rejected DDL must not create the collection"
3655        );
3656    }
3657}