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