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