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