Skip to main content

reddb_server/runtime/
impl_ddl.rs

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