Skip to main content

reddb_server/runtime/
impl_ddl.rs

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