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