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