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