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        let index_growth_rows = entity_fields
1597            .iter()
1598            .map(|(_, fields)| fields.clone())
1599            .collect::<Vec<_>>();
1600        self.admit_non_evictable_growth(
1601            crate::storage::memory_pools::MemoryPool::IndexMemory,
1602            &format!("create index {}", query.name),
1603            crate::runtime::memory_admission::estimate_index_growth(
1604                &index_growth_rows,
1605                &query.columns,
1606            ),
1607        )?;
1608
1609        // Build the index
1610        let indexed_count = self
1611            .inner
1612            .index_store
1613            .create_index(
1614                &query.name,
1615                &query.table,
1616                &query.columns,
1617                method_kind,
1618                query.unique,
1619                &entity_fields,
1620            )
1621            .map_err(RedDBError::Internal)?;
1622
1623        let analyzed = crate::storage::query::planner::stats_catalog::analyze_entity_fields(
1624            &query.table,
1625            &entity_fields,
1626        );
1627        crate::storage::query::planner::stats_catalog::persist_table_stats(&store, &analyzed);
1628        self.invalidate_plan_cache();
1629
1630        // Register metadata
1631        self.inner
1632            .index_store
1633            .register(super::index_store::RegisteredIndex {
1634                name: query.name.clone(),
1635                collection: query.table.clone(),
1636                columns: query.columns.clone(),
1637                method: method_kind,
1638                unique: query.unique,
1639            });
1640        self.persist_runtime_index_descriptor(super::index_store::RegisteredIndex {
1641            name: query.name.clone(),
1642            collection: query.table.clone(),
1643            columns: query.columns.clone(),
1644            method: method_kind,
1645            unique: query.unique,
1646        })?;
1647        // Issue #120 — surface the index name + indexed columns in
1648        // the schema-vocabulary so AskPipeline (#121) can resolve
1649        // "the email index" back to its collection.
1650        self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::CreateIndex {
1651            collection: query.table.clone(),
1652            index: query.name.clone(),
1653            columns: query.columns.clone(),
1654        });
1655
1656        let method_str = format!("{}", query.method);
1657        let unique_str = if query.unique { "unique " } else { "" };
1658        let cols = query.columns.join(", ");
1659        let total_count = entity_fields.len();
1660        let coverage_detail =
1661            if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. })
1662                && total_count > 0
1663                && indexed_count == 0
1664            {
1665                format!(
1666                    "{} of {} entities indexed — no indexable geo value in '{}'; expected {}",
1667                    indexed_count,
1668                    total_count,
1669                    cols,
1670                    crate::geo::RECOGNIZED_GEO_SHAPES
1671                )
1672            } else if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. }) {
1673                format!("{} of {} entities indexed", indexed_count, total_count)
1674            } else {
1675                format!("{} entities indexed", indexed_count)
1676            };
1677
1678        Ok(RuntimeQueryResult::ok_message(
1679            raw_query.to_string(),
1680            &format!(
1681                "{}index '{}' created on '{}' ({}) using {} ({})",
1682                unique_str, query.name, query.table, cols, method_str, coverage_detail
1683            ),
1684            "create",
1685        ))
1686    }
1687
1688    /// Execute DROP INDEX
1689    ///
1690    /// Removes an index from a collection.
1691    pub fn execute_drop_index(
1692        &self,
1693        raw_query: &str,
1694        query: &DropIndexQuery,
1695    ) -> RedDBResult<RuntimeQueryResult> {
1696        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1697        let store = self.inner.db.store();
1698
1699        // Verify the table exists
1700        if store.get_collection(&query.table).is_none() {
1701            if query.if_exists {
1702                return Ok(RuntimeQueryResult::ok_message(
1703                    raw_query.to_string(),
1704                    &format!("table '{}' does not exist", query.table),
1705                    "drop",
1706                ));
1707            }
1708            return Err(RedDBError::NotFound(format!(
1709                "table '{}' not found",
1710                query.table
1711            )));
1712        }
1713
1714        // Remove from IndexStore
1715        self.inner.index_store.drop_index(&query.name, &query.table);
1716        self.persist_runtime_index_drop(&query.table, &query.name)?;
1717        self.invalidate_plan_cache();
1718        // Issue #120 — keep the schema-vocabulary index entry in sync.
1719        self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::DropIndex {
1720            collection: query.table.clone(),
1721            index: query.name.clone(),
1722        });
1723
1724        Ok(RuntimeQueryResult::ok_message(
1725            raw_query.to_string(),
1726            &format!("index '{}' dropped from '{}'", query.name, query.table),
1727            "drop",
1728        ))
1729    }
1730
1731    fn execute_drop_typed_collection(
1732        &self,
1733        raw_query: &str,
1734        name: &str,
1735        if_exists: bool,
1736        expected_model: CollectionModel,
1737        label: &str,
1738    ) -> RedDBResult<RuntimeQueryResult> {
1739        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1740        if is_system_schema_name(name) {
1741            return Err(RedDBError::Query("system schema is read-only".to_string()));
1742        }
1743        let store = self.inner.db.store();
1744        if store.get_collection(name).is_none() {
1745            if if_exists {
1746                return Ok(RuntimeQueryResult::ok_message(
1747                    raw_query.to_string(),
1748                    &format!("{label} '{name}' does not exist"),
1749                    "drop",
1750                ));
1751            }
1752            return Err(RedDBError::NotFound(format!("{label} '{name}' not found")));
1753        }
1754
1755        let actual = self
1756            .inner
1757            .db
1758            .collection_contract(name)
1759            .map(|contract| contract.declared_model)
1760            .map(Ok)
1761            .unwrap_or_else(|| {
1762                polymorphic_resolver::resolve(name, &self.inner.db.catalog_model_snapshot())
1763            })?;
1764        polymorphic_resolver::ensure_model_match(expected_model, actual)?;
1765        self.drop_collection_storage(raw_query, name, label)
1766    }
1767
1768    pub fn execute_truncate(
1769        &self,
1770        raw_query: &str,
1771        query: &TruncateQuery,
1772    ) -> RedDBResult<RuntimeQueryResult> {
1773        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1774        if is_system_schema_name(&query.name) {
1775            return Err(RedDBError::Query("system schema is read-only".to_string()));
1776        }
1777
1778        let label = query
1779            .model
1780            .map(polymorphic_resolver::model_name)
1781            .unwrap_or("collection");
1782        let store = self.inner.db.store();
1783        if store.get_collection(&query.name).is_none() {
1784            if query.if_exists {
1785                return Ok(RuntimeQueryResult::ok_message(
1786                    raw_query.to_string(),
1787                    &format!("{label} '{}' does not exist", query.name),
1788                    "truncate",
1789                ));
1790            }
1791            return Err(RedDBError::NotFound(format!(
1792                "{label} '{}' not found",
1793                query.name
1794            )));
1795        }
1796
1797        let actual =
1798            polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
1799        if let Some(expected) = query.model {
1800            polymorphic_resolver::ensure_model_match(expected, actual)?;
1801        }
1802
1803        if actual == CollectionModel::Queue {
1804            return self.execute_queue_command(
1805                raw_query,
1806                &QueueCommand::Purge {
1807                    queue: query.name.clone(),
1808                },
1809            );
1810        }
1811
1812        // Count before wiping so we can emit the aggregated truncate event.
1813        let affected = self.truncate_collection_entities(&query.name)?;
1814        // Emit 1 truncate event (not N delete events) for event-enabled collections.
1815        crate::runtime::mutation::emit_truncate_event_for_collection(self, &query.name, affected)?;
1816        self.inner.db.invalidate_vector_index(&query.name);
1817        self.clear_table_planner_stats(&query.name);
1818        self.invalidate_result_cache();
1819
1820        Ok(RuntimeQueryResult::ok_message(
1821            raw_query.to_string(),
1822            &format!(
1823                "{affected} entities truncated from {label} '{}'",
1824                query.name
1825            ),
1826            "truncate",
1827        ))
1828    }
1829
1830    fn truncate_collection_entities(&self, name: &str) -> RedDBResult<u64> {
1831        let store = self.inner.db.store();
1832        let Some(manager) = store.get_collection(name) else {
1833            return Ok(0);
1834        };
1835        let entities = manager.query_all(|_| true);
1836        if entities.is_empty() {
1837            return Ok(0);
1838        }
1839
1840        for entity in &entities {
1841            let fields = entity_index_fields(&entity.data);
1842            self.inner
1843                .index_store
1844                .index_entity_delete(name, entity.id, &fields)
1845                .map_err(RedDBError::Internal)?;
1846        }
1847
1848        let ids = entities.iter().map(|entity| entity.id).collect::<Vec<_>>();
1849        let deleted_ids = store
1850            .delete_batch(name, &ids)
1851            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1852        for id in &deleted_ids {
1853            store.context_index().remove_entity(*id);
1854        }
1855        Ok(deleted_ids.len() as u64)
1856    }
1857
1858    fn drop_collection_storage(
1859        &self,
1860        raw_query: &str,
1861        name: &str,
1862        label: &str,
1863    ) -> RedDBResult<RuntimeQueryResult> {
1864        let store = self.inner.db.store();
1865
1866        // Emit 1 collection_dropped event before storage is wiped.
1867        // Queue is preserved; subscription is removed with the contract below.
1868        let final_count = store
1869            .get_collection(name)
1870            .map(|manager| manager.query_all(|_| true).len() as u64)
1871            .unwrap_or(0);
1872        crate::runtime::mutation::emit_collection_dropped_event_for_collection(
1873            self,
1874            name,
1875            final_count,
1876        )?;
1877
1878        let orphaned_indices: Vec<String> = self
1879            .inner
1880            .index_store
1881            .list_indices(name)
1882            .into_iter()
1883            .map(|index| index.name)
1884            .collect();
1885        for index_name in &orphaned_indices {
1886            self.inner.index_store.drop_index(index_name, name);
1887        }
1888
1889        store
1890            .drop_collection(name)
1891            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1892        self.inner.db.invalidate_vector_index(name);
1893        self.inner.db.clear_collection_default_ttl_ms(name);
1894        self.inner
1895            .db
1896            .remove_collection_contract(name)
1897            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1898        self.clear_table_planner_stats(name);
1899        self.invalidate_result_cache();
1900        if let Some(store) = self.inner.auth_store.read().clone() {
1901            store.invalidate_visible_collections_cache();
1902        }
1903        self.inner
1904            .db
1905            .persist_metadata()
1906            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1907        self.schema_vocabulary_apply(
1908            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
1909                collection: name.to_string(),
1910            },
1911        );
1912
1913        Ok(RuntimeQueryResult::ok_message(
1914            raw_query.to_string(),
1915            &format!("{label} '{name}' dropped"),
1916            "drop",
1917        ))
1918    }
1919}
1920
1921pub(crate) fn is_system_schema_name(name: &str) -> bool {
1922    name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_")
1923}
1924
1925fn entity_index_fields(data: &EntityData) -> Vec<(String, Value)> {
1926    match data {
1927        EntityData::Row(row) => {
1928            if let Some(ref named) = row.named {
1929                named
1930                    .iter()
1931                    .map(|(key, value)| (key.clone(), value.clone()))
1932                    .collect()
1933            } else if let Some(ref schema) = row.schema {
1934                schema
1935                    .iter()
1936                    .zip(row.columns.iter())
1937                    .map(|(key, value)| (key.clone(), value.clone()))
1938                    .collect()
1939            } else {
1940                Vec::new()
1941            }
1942        }
1943        EntityData::Node(node) => node
1944            .properties
1945            .iter()
1946            .map(|(key, value)| (key.clone(), value.clone()))
1947            .collect(),
1948        _ => Vec::new(),
1949    }
1950}
1951
1952/// DDL-time gate for a declared AI policy (issue #1271). Each declared
1953/// modality block is checked against the provider capability matrix
1954/// (#1269): a provider/model that cannot serve the requested modality is
1955/// rejected before the collection is created. Uses the built-in registry
1956/// (no per-deployment overrides at DDL time).
1957fn validate_ai_policy_modalities(policy: &crate::catalog::AiPolicy) -> RedDBResult<()> {
1958    use crate::runtime::ai::provider_capabilities::{Modality, Registry};
1959    let registry = Registry::new();
1960    let check = |provider: &str, model: &str, modality: Modality| -> RedDBResult<()> {
1961        registry
1962            .validate_policy_modality(provider, model, modality)
1963            .map_err(|err| RedDBError::Query(err.to_string()))
1964    };
1965    if let Some(embed) = &policy.embed {
1966        check(&embed.provider, &embed.model, Modality::Embed)?;
1967    }
1968    if let Some(moderate) = &policy.moderate {
1969        check(&moderate.provider, &moderate.model, Modality::Moderate)?;
1970    }
1971    if let Some(vision) = &policy.vision {
1972        check(&vision.provider, &vision.model, Modality::Vision)?;
1973    }
1974    Ok(())
1975}
1976
1977fn collection_contract_from_create_table(
1978    query: &CreateTableQuery,
1979) -> RedDBResult<crate::physical::CollectionContract> {
1980    let now = current_unix_ms();
1981    let mut declared_columns: Vec<crate::physical::DeclaredColumnContract> = query
1982        .columns
1983        .iter()
1984        .map(declared_column_contract_from_ddl)
1985        .collect();
1986    if query.timestamps {
1987        // Opt-in `WITH timestamps = true` auto-adds two user-visible
1988        // columns that the write path populates from
1989        // UnifiedEntity::created_at/updated_at. BIGINT unix-ms, NOT NULL.
1990        declared_columns.push(crate::physical::DeclaredColumnContract {
1991            name: "created_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        declared_columns.push(crate::physical::DeclaredColumnContract {
2004            name: "updated_at".to_string(),
2005            data_type: "BIGINT".to_string(),
2006            sql_type: Some(crate::storage::schema::SqlTypeName::simple("BIGINT")),
2007            not_null: true,
2008            default: None,
2009            compress: None,
2010            unique: false,
2011            primary_key: false,
2012            enum_variants: Vec::new(),
2013            array_element: None,
2014            decimal_precision: None,
2015        });
2016    }
2017    Ok(crate::physical::CollectionContract {
2018        name: query.name.clone(),
2019        declared_model: crate::catalog::CollectionModel::Table,
2020        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2021        origin: crate::physical::ContractOrigin::Explicit,
2022        version: 1,
2023        created_at_unix_ms: now,
2024        updated_at_unix_ms: now,
2025        default_ttl_ms: query.default_ttl_ms,
2026        vector_dimension: None,
2027        vector_metric: None,
2028        context_index_fields: query.context_index_fields.clone(),
2029        declared_columns,
2030        table_def: Some(build_table_def_from_create_table(query)?),
2031        timestamps_enabled: query.timestamps,
2032        context_index_enabled: query.context_index_enabled
2033            || !query.context_index_fields.is_empty(),
2034        metrics_raw_retention_ms: None,
2035        metrics_rollup_policies: Vec::new(),
2036        metrics_tenant_identity: None,
2037        metrics_namespace: None,
2038        append_only: query.append_only,
2039        subscriptions: query.subscriptions.clone(),
2040        analytics_config: Vec::new(),
2041        session_key: None,
2042        session_gap_ms: None,
2043        retention_duration_ms: None,
2044        analytical_storage: None,
2045        ai_policy: query.ai_policy.clone(),
2046    })
2047}
2048
2049fn default_collection_contract_for_existing_table(
2050    name: &str,
2051) -> crate::physical::CollectionContract {
2052    let now = current_unix_ms();
2053    crate::physical::CollectionContract {
2054        name: name.to_string(),
2055        declared_model: crate::catalog::CollectionModel::Table,
2056        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2057        origin: crate::physical::ContractOrigin::Explicit,
2058        version: 0,
2059        created_at_unix_ms: now,
2060        updated_at_unix_ms: now,
2061        default_ttl_ms: None,
2062        vector_dimension: None,
2063        vector_metric: None,
2064        context_index_fields: Vec::new(),
2065        declared_columns: Vec::new(),
2066        table_def: Some(crate::storage::schema::TableDef::new(name.to_string())),
2067        timestamps_enabled: false,
2068        context_index_enabled: false,
2069        metrics_raw_retention_ms: None,
2070        metrics_rollup_policies: Vec::new(),
2071        metrics_tenant_identity: None,
2072        metrics_namespace: None,
2073        append_only: false,
2074        subscriptions: Vec::new(),
2075        analytics_config: Vec::new(),
2076        session_key: None,
2077        session_gap_ms: None,
2078        retention_duration_ms: None,
2079        analytical_storage: None,
2080
2081        ai_policy: None,
2082    }
2083}
2084
2085fn keyed_collection_contract(
2086    name: &str,
2087    model: crate::catalog::CollectionModel,
2088    analytics_config: Vec<crate::catalog::AnalyticsViewDescriptor>,
2089) -> crate::physical::CollectionContract {
2090    let now = current_unix_ms();
2091    crate::physical::CollectionContract {
2092        name: name.to_string(),
2093        declared_model: model,
2094        schema_mode: crate::catalog::SchemaMode::Dynamic,
2095        origin: crate::physical::ContractOrigin::Explicit,
2096        version: 1,
2097        created_at_unix_ms: now,
2098        updated_at_unix_ms: now,
2099        default_ttl_ms: None,
2100        vector_dimension: None,
2101        vector_metric: None,
2102        context_index_fields: Vec::new(),
2103        declared_columns: Vec::new(),
2104        table_def: None,
2105        timestamps_enabled: false,
2106        context_index_enabled: false,
2107        metrics_raw_retention_ms: None,
2108        metrics_rollup_policies: Vec::new(),
2109        metrics_tenant_identity: None,
2110        metrics_namespace: None,
2111        append_only: false,
2112        subscriptions: Vec::new(),
2113        analytics_config,
2114        session_key: None,
2115        session_gap_ms: None,
2116        retention_duration_ms: None,
2117        analytical_storage: None,
2118
2119        ai_policy: None,
2120    }
2121}
2122
2123fn metrics_collection_contract(query: &CreateTableQuery) -> crate::physical::CollectionContract {
2124    let now = current_unix_ms();
2125    crate::physical::CollectionContract {
2126        name: query.name.clone(),
2127        declared_model: crate::catalog::CollectionModel::Metrics,
2128        schema_mode: crate::catalog::SchemaMode::SemiStructured,
2129        origin: crate::physical::ContractOrigin::Explicit,
2130        version: 1,
2131        created_at_unix_ms: now,
2132        updated_at_unix_ms: now,
2133        default_ttl_ms: query.default_ttl_ms,
2134        vector_dimension: None,
2135        vector_metric: None,
2136        context_index_fields: Vec::new(),
2137        declared_columns: Vec::new(),
2138        table_def: None,
2139        timestamps_enabled: false,
2140        context_index_enabled: false,
2141        metrics_raw_retention_ms: query.default_ttl_ms,
2142        metrics_rollup_policies: query.metrics_rollup_policies.clone(),
2143        metrics_tenant_identity: Some(
2144            query
2145                .tenant_by
2146                .clone()
2147                .unwrap_or_else(|| "current_tenant".to_string()),
2148        ),
2149        metrics_namespace: Some("default".to_string()),
2150        append_only: true,
2151        subscriptions: Vec::new(),
2152        analytics_config: Vec::new(),
2153        session_key: None,
2154        session_gap_ms: None,
2155        retention_duration_ms: None,
2156        analytical_storage: None,
2157
2158        ai_policy: None,
2159    }
2160}
2161
2162fn vector_collection_contract(query: &CreateVectorQuery) -> crate::physical::CollectionContract {
2163    let now = current_unix_ms();
2164    crate::physical::CollectionContract {
2165        name: query.name.clone(),
2166        declared_model: crate::catalog::CollectionModel::Vector,
2167        schema_mode: crate::catalog::SchemaMode::Dynamic,
2168        origin: crate::physical::ContractOrigin::Explicit,
2169        version: 1,
2170        created_at_unix_ms: now,
2171        updated_at_unix_ms: now,
2172        default_ttl_ms: None,
2173        vector_dimension: Some(query.dimension),
2174        vector_metric: Some(query.metric),
2175        context_index_fields: Vec::new(),
2176        declared_columns: Vec::new(),
2177        table_def: None,
2178        timestamps_enabled: false,
2179        context_index_enabled: false,
2180        metrics_raw_retention_ms: None,
2181        metrics_rollup_policies: Vec::new(),
2182        metrics_tenant_identity: None,
2183        metrics_namespace: None,
2184        append_only: false,
2185        subscriptions: Vec::new(),
2186        analytics_config: Vec::new(),
2187        session_key: None,
2188        session_gap_ms: None,
2189        retention_duration_ms: None,
2190        analytical_storage: None,
2191
2192        ai_policy: None,
2193    }
2194}
2195
2196fn declared_column_contract_from_ddl(
2197    column: &CreateColumnDef,
2198) -> crate::physical::DeclaredColumnContract {
2199    crate::physical::DeclaredColumnContract {
2200        name: column.name.clone(),
2201        data_type: column.data_type.clone(),
2202        sql_type: Some(column.sql_type.clone()),
2203        not_null: column.not_null,
2204        default: column.default.clone(),
2205        compress: column.compress,
2206        unique: column.unique,
2207        primary_key: column.primary_key,
2208        enum_variants: column.enum_variants.clone(),
2209        array_element: column.array_element.clone(),
2210        decimal_precision: column.decimal_precision,
2211    }
2212}
2213
2214fn apply_alter_operations_to_contract(
2215    contract: &mut crate::physical::CollectionContract,
2216    operations: &[AlterOperation],
2217) {
2218    if contract.table_def.is_none() {
2219        contract.table_def = Some(crate::storage::schema::TableDef::new(contract.name.clone()));
2220    }
2221    for operation in operations {
2222        match operation {
2223            AlterOperation::AddColumn(column) => {
2224                if !contract
2225                    .declared_columns
2226                    .iter()
2227                    .any(|existing| existing.name == column.name)
2228                {
2229                    contract
2230                        .declared_columns
2231                        .push(declared_column_contract_from_ddl(column));
2232                }
2233                if let Some(table_def) = contract.table_def.as_mut() {
2234                    if table_def.get_column(&column.name).is_none() {
2235                        if let Ok(column_def) = column_def_from_ddl(column) {
2236                            if column.primary_key {
2237                                table_def.primary_key.push(column.name.clone());
2238                                table_def.constraints.push(
2239                                    crate::storage::schema::Constraint::new(
2240                                        format!("pk_{}", column.name),
2241                                        crate::storage::schema::ConstraintType::PrimaryKey,
2242                                    )
2243                                    .on_columns(vec![column.name.clone()]),
2244                                );
2245                            }
2246                            if column.unique {
2247                                table_def.constraints.push(
2248                                    crate::storage::schema::Constraint::new(
2249                                        format!("uniq_{}", column.name),
2250                                        crate::storage::schema::ConstraintType::Unique,
2251                                    )
2252                                    .on_columns(vec![column.name.clone()]),
2253                                );
2254                            }
2255                            if column.not_null {
2256                                table_def.constraints.push(
2257                                    crate::storage::schema::Constraint::new(
2258                                        format!("not_null_{}", column.name),
2259                                        crate::storage::schema::ConstraintType::NotNull,
2260                                    )
2261                                    .on_columns(vec![column.name.clone()]),
2262                                );
2263                            }
2264                            table_def.columns.push(column_def);
2265                        }
2266                    }
2267                }
2268            }
2269            AlterOperation::DropColumn(name) => {
2270                contract
2271                    .declared_columns
2272                    .retain(|column| column.name != *name);
2273                if let Some(table_def) = contract.table_def.as_mut() {
2274                    if let Some(index) = table_def.column_index(name) {
2275                        table_def.columns.remove(index);
2276                    }
2277                    table_def.primary_key.retain(|column| column != name);
2278                    table_def.constraints.retain(|constraint| {
2279                        !constraint.columns.iter().any(|column| column == name)
2280                    });
2281                    table_def
2282                        .indexes
2283                        .retain(|index| !index.columns.iter().any(|column| column == name));
2284                }
2285            }
2286            AlterOperation::RenameColumn { from, to } => {
2287                if contract
2288                    .declared_columns
2289                    .iter()
2290                    .any(|column| column.name == *to)
2291                {
2292                    continue;
2293                }
2294                if let Some(column) = contract
2295                    .declared_columns
2296                    .iter_mut()
2297                    .find(|column| column.name == *from)
2298                {
2299                    column.name = to.clone();
2300                }
2301                if let Some(table_def) = contract.table_def.as_mut() {
2302                    if let Some(column) = table_def
2303                        .columns
2304                        .iter_mut()
2305                        .find(|column| column.name == *from)
2306                    {
2307                        column.name = to.clone();
2308                    }
2309                    for primary_key in &mut table_def.primary_key {
2310                        if *primary_key == *from {
2311                            *primary_key = to.clone();
2312                        }
2313                    }
2314                    for constraint in &mut table_def.constraints {
2315                        for column in &mut constraint.columns {
2316                            if *column == *from {
2317                                *column = to.clone();
2318                            }
2319                        }
2320                        if let Some(ref_columns) = constraint.ref_columns.as_mut() {
2321                            for column in ref_columns {
2322                                if *column == *from {
2323                                    *column = to.clone();
2324                                }
2325                            }
2326                        }
2327                    }
2328                    for index in &mut table_def.indexes {
2329                        for column in &mut index.columns {
2330                            if *column == *from {
2331                                *column = to.clone();
2332                            }
2333                        }
2334                    }
2335                }
2336            }
2337            // Partition ops don't touch the column contract — metadata is
2338            // persisted separately via `red_config.partition.*`.
2339            AlterOperation::AttachPartition { .. } | AlterOperation::DetachPartition { .. } => {}
2340            // RLS toggles don't touch the column contract — flag is persisted
2341            // separately via `red_config.rls.enabled.{table}` and enforced
2342            // through the in-memory `rls_enabled_tables` set.
2343            AlterOperation::EnableRowLevelSecurity | AlterOperation::DisableRowLevelSecurity => {}
2344            // Phase 2.5.4: tenancy toggles persist via `red_config.tenant_tables.*`
2345            // and are enforced through `tenant_tables` + RLS auto-policy.
2346            AlterOperation::EnableTenancy { .. } | AlterOperation::DisableTenancy => {}
2347            AlterOperation::SetAppendOnly(on) => {
2348                contract.append_only = *on;
2349            }
2350            // VCS opt-in is persisted to red_vcs_settings by the
2351            // executor, not the contract — nothing to do here.
2352            AlterOperation::SetVersioned(_) => {}
2353            AlterOperation::EnableEvents(subscription) => {
2354                let mut subscription = subscription.clone();
2355                subscription.source = contract.name.clone();
2356                subscription.enabled = true;
2357                if let Some(existing) = contract
2358                    .subscriptions
2359                    .iter_mut()
2360                    .find(|existing| existing.target_queue == subscription.target_queue)
2361                {
2362                    *existing = subscription;
2363                } else {
2364                    contract.subscriptions.push(subscription);
2365                }
2366            }
2367            AlterOperation::DisableEvents => {
2368                for subscription in &mut contract.subscriptions {
2369                    subscription.enabled = false;
2370                }
2371            }
2372            AlterOperation::AddSubscription { name, descriptor } => {
2373                let mut sub = descriptor.clone();
2374                sub.name = name.clone();
2375                sub.source = contract.name.clone();
2376                sub.enabled = true;
2377                if let Some(existing) = contract.subscriptions.iter_mut().find(|s| s.name == *name)
2378                {
2379                    *existing = sub;
2380                } else {
2381                    contract.subscriptions.push(sub);
2382                }
2383            }
2384            AlterOperation::DropSubscription { name } => {
2385                contract.subscriptions.retain(|s| s.name != *name);
2386            }
2387            // Signer registry mutations live in `red_config` outside the
2388            // contract surface — the executor applied them directly via
2389            // `signed_writes_kind::{add,revoke}_signer`. Nothing to fold
2390            // into the column-shaped contract.
2391            AlterOperation::AddSigner { .. } | AlterOperation::RevokeSigner { .. } => {}
2392            AlterOperation::SetRetention { duration_ms } => {
2393                contract.retention_duration_ms = Some(*duration_ms);
2394            }
2395            AlterOperation::UnsetRetention => {
2396                contract.retention_duration_ms = None;
2397            }
2398            // Issue #801 — fold analytics lifecycle into the WAL-backed
2399            // `analytics_config` so the change is durable and the
2400            // `<graph>.<output>` resolver picks it up on the next read.
2401            AlterOperation::AddAnalytics(views) => {
2402                for view in views {
2403                    // Idempotent: skip outputs already enabled so a repeated
2404                    // ADD never duplicates state. The first declaration wins —
2405                    // re-declaring options requires DROP then ADD.
2406                    if !contract
2407                        .analytics_config
2408                        .iter()
2409                        .any(|existing| existing.output == view.output)
2410                    {
2411                        contract.analytics_config.push(view.clone());
2412                    }
2413                }
2414            }
2415            AlterOperation::DropAnalytics(output) => {
2416                contract
2417                    .analytics_config
2418                    .retain(|view| view.output != *output);
2419            }
2420        }
2421    }
2422}
2423
2424/// Issue #580 — returns true if the contract carries at least one
2425/// column the retention filter can use as a timestamp anchor:
2426/// either `WITH timestamps = true` (auto `created_at` / `updated_at`)
2427/// or a user-declared column with a temporal data_type.
2428pub(crate) fn retention_timestamp_column_exists(
2429    contract: &crate::physical::CollectionContract,
2430) -> bool {
2431    if contract.timestamps_enabled {
2432        return true;
2433    }
2434    if matches!(
2435        contract.declared_model,
2436        crate::catalog::CollectionModel::TimeSeries | crate::catalog::CollectionModel::Metrics
2437    ) {
2438        // Time-series and metrics collections carry an intrinsic
2439        // timestamp axis on every row even without a declared column;
2440        // retention has a natural anchor.
2441        return true;
2442    }
2443    contract
2444        .declared_columns
2445        .iter()
2446        .any(|column| is_temporal_data_type(&column.data_type))
2447}
2448
2449fn is_temporal_data_type(data_type: &str) -> bool {
2450    let upper = data_type.to_ascii_uppercase();
2451    matches!(
2452        upper.as_str(),
2453        "TIMESTAMP" | "TIMESTAMPMS" | "TIMESTAMP_MS" | "DATETIME" | "DATE"
2454    )
2455}
2456
2457fn validate_event_subscriptions(
2458    runtime: &RedDBRuntime,
2459    source: &str,
2460    subscriptions: &[crate::catalog::SubscriptionDescriptor],
2461) -> RedDBResult<()> {
2462    for subscription in subscriptions
2463        .iter()
2464        .filter(|subscription| subscription.enabled)
2465    {
2466        if subscription.all_tenants && crate::runtime::impl_core::current_tenant().is_some() {
2467            return Err(RedDBError::Query(
2468                "cross-tenant subscription requires cluster-admin capability (events:cluster_subscribe)".to_string(),
2469            ));
2470        }
2471        validate_subscription_auth(runtime, source, subscription)?;
2472        if subscription.target_queue == source
2473            || subscription_would_create_cycle(
2474                &runtime.inner.db,
2475                source,
2476                &subscription.target_queue,
2477            )
2478        {
2479            return Err(RedDBError::Query(
2480                "subscription would create cycle".to_string(),
2481            ));
2482        }
2483        audit_subscription_redact_gap(runtime, source, subscription);
2484    }
2485    Ok(())
2486}
2487
2488fn validate_subscription_auth(
2489    runtime: &RedDBRuntime,
2490    source: &str,
2491    subscription: &crate::catalog::SubscriptionDescriptor,
2492) -> RedDBResult<()> {
2493    let auth_store = match runtime.inner.auth_store.read().clone() {
2494        Some(store) => store,
2495        None => return Ok(()),
2496    };
2497    let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2498        Some(identity) => identity,
2499        None => return Ok(()),
2500    };
2501    let tenant = crate::runtime::impl_core::current_tenant();
2502    let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2503
2504    if auth_store.iam_authorization_enabled() {
2505        let ctx = crate::auth::policies::EvalContext {
2506            principal_tenant: tenant.clone(),
2507            current_tenant: tenant.clone(),
2508            peer_ip: None,
2509            mfa_present: false,
2510            now_ms: crate::auth::now_ms(),
2511            principal_is_admin_role: role == crate::auth::Role::Admin,
2512            principal_is_platform_scoped: principal.tenant.is_none(),
2513        };
2514        let mut source_resource = crate::auth::policies::ResourceRef::new("table", source);
2515        if let Some(t) = tenant.as_deref() {
2516            source_resource = source_resource.with_tenant(t.to_string());
2517        }
2518        if !auth_store.check_policy_authz_with_role(
2519            &principal,
2520            "select",
2521            &source_resource,
2522            &ctx,
2523            role,
2524        ) {
2525            return Err(RedDBError::Query(format!(
2526                "permission denied: principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
2527                principal, source_resource.kind, source_resource.name
2528            )));
2529        }
2530
2531        let mut target_resource =
2532            crate::auth::policies::ResourceRef::new("queue", subscription.target_queue.clone());
2533        if let Some(t) = tenant.as_deref() {
2534            target_resource = target_resource.with_tenant(t.to_string());
2535        }
2536        if !auth_store.check_policy_authz_with_role(
2537            &principal,
2538            "write",
2539            &target_resource,
2540            &ctx,
2541            role,
2542        ) {
2543            return Err(RedDBError::Query(format!(
2544                "permission denied: principal=`{}` action=`write` resource=`{}:{}` denied by IAM policy",
2545                principal, target_resource.kind, target_resource.name
2546            )));
2547        }
2548        return Ok(());
2549    }
2550
2551    let ctx = crate::auth::privileges::AuthzContext {
2552        principal: &username,
2553        effective_role: role,
2554        tenant: tenant.as_deref(),
2555    };
2556    auth_store
2557        .check_grant(
2558            &ctx,
2559            crate::auth::privileges::Action::Select,
2560            &crate::auth::privileges::Resource::table_from_name(source),
2561        )
2562        .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2563    auth_store
2564        .check_grant(
2565            &ctx,
2566            crate::auth::privileges::Action::Insert,
2567            &crate::auth::privileges::Resource::table_from_name(&subscription.target_queue),
2568        )
2569        .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2570    Ok(())
2571}
2572
2573fn audit_subscription_redact_gap(
2574    runtime: &RedDBRuntime,
2575    source: &str,
2576    subscription: &crate::catalog::SubscriptionDescriptor,
2577) {
2578    let auth_store = match runtime.inner.auth_store.read().clone() {
2579        Some(store) if store.iam_authorization_enabled() => store,
2580        _ => return,
2581    };
2582    let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2583        Some(identity) => identity,
2584        None => return,
2585    };
2586    let tenant = crate::runtime::impl_core::current_tenant();
2587    let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2588    let missing = subscription_redact_gap_columns(&auth_store, &principal, source, subscription);
2589    if missing.is_empty() {
2590        return;
2591    }
2592
2593    let columns = missing.into_iter().collect::<Vec<_>>().join(", ");
2594    tracing::warn!(
2595        target: "reddb::operator",
2596        "subscription_redact_gap: source={} target_queue={} columns=[{}]",
2597        source,
2598        subscription.target_queue,
2599        columns
2600    );
2601    let mut event = AuditEvent::builder("subscription_redact_gap")
2602        .principal(username)
2603        .source(AuditAuthSource::System)
2604        .resource(format!(
2605            "subscription:{}->{}",
2606            source, subscription.target_queue
2607        ))
2608        .outcome(Outcome::Success)
2609        .field(AuditFieldEscaper::field("source", source))
2610        .field(AuditFieldEscaper::field(
2611            "target_queue",
2612            subscription.target_queue.clone(),
2613        ))
2614        .field(AuditFieldEscaper::field(
2615            "subscription",
2616            subscription.name.clone(),
2617        ))
2618        .field(AuditFieldEscaper::field("columns", columns))
2619        .field(AuditFieldEscaper::field("role", role.as_str()));
2620    if let Some(t) = tenant {
2621        event = event.tenant(t);
2622    }
2623    runtime.inner.audit_log.record_event(event.build());
2624}
2625
2626fn subscription_redact_gap_columns(
2627    auth_store: &crate::auth::store::AuthStore,
2628    principal: &crate::auth::UserId,
2629    source: &str,
2630    subscription: &crate::catalog::SubscriptionDescriptor,
2631) -> BTreeSet<String> {
2632    let redacted: HashSet<String> = subscription
2633        .redact_fields
2634        .iter()
2635        .map(|field| field.to_ascii_lowercase())
2636        .collect();
2637    auth_store
2638        .effective_policies(principal)
2639        .iter()
2640        .flat_map(|policy| policy.statements.iter())
2641        .filter(|statement| statement.effect == crate::auth::policies::Effect::Deny)
2642        .filter(|statement| statement.actions.iter().any(action_pattern_matches_select))
2643        .flat_map(|statement| statement.resources.iter())
2644        .filter_map(|resource| denied_column_for_source(resource, source))
2645        .filter(|column| !redact_covers_column(&redacted, source, column))
2646        .collect()
2647}
2648
2649fn action_pattern_matches_select(pattern: &crate::auth::policies::ActionPattern) -> bool {
2650    match pattern {
2651        crate::auth::policies::ActionPattern::Wildcard => true,
2652        crate::auth::policies::ActionPattern::Exact(action) => action == "select",
2653        crate::auth::policies::ActionPattern::Prefix(prefix) => {
2654            "select".len() > prefix.len() + 1
2655                && "select".starts_with(prefix)
2656                && "select".as_bytes()[prefix.len()] == b':'
2657        }
2658    }
2659}
2660
2661fn denied_column_for_source(
2662    resource: &crate::auth::policies::ResourcePattern,
2663    source: &str,
2664) -> Option<String> {
2665    let crate::auth::policies::ResourcePattern::Exact { kind, name } = resource else {
2666        return None;
2667    };
2668    if kind != "column" {
2669        return None;
2670    }
2671    let column = crate::auth::ColumnRef::parse_resource_name(name).ok()?;
2672    (column.table_resource_name() == source).then_some(column.column)
2673}
2674
2675fn redact_covers_column(redacted: &HashSet<String>, source: &str, column: &str) -> bool {
2676    let column = column.to_ascii_lowercase();
2677    let qualified = format!("{}.{}", source.to_ascii_lowercase(), column);
2678    redacted.contains("*") || redacted.contains(&column) || redacted.contains(&qualified)
2679}
2680
2681fn subscription_would_create_cycle(
2682    db: &crate::storage::unified::devx::RedDB,
2683    source: &str,
2684    target: &str,
2685) -> bool {
2686    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
2687    for contract in db.collection_contracts() {
2688        for subscription in contract
2689            .subscriptions
2690            .into_iter()
2691            .filter(|subscription| subscription.enabled)
2692        {
2693            graph
2694                .entry(subscription.source)
2695                .or_default()
2696                .push(subscription.target_queue);
2697        }
2698    }
2699    graph
2700        .entry(source.to_string())
2701        .or_default()
2702        .push(target.to_string());
2703
2704    let mut stack = vec![target.to_string()];
2705    let mut seen = HashSet::new();
2706    while let Some(node) = stack.pop() {
2707        if node == source {
2708            return true;
2709        }
2710        if !seen.insert(node.clone()) {
2711            continue;
2712        }
2713        if let Some(next) = graph.get(&node) {
2714            stack.extend(next.iter().cloned());
2715        }
2716    }
2717    false
2718}
2719
2720pub(crate) fn ensure_event_target_queue_pub(
2721    runtime: &RedDBRuntime,
2722    queue: &str,
2723) -> RedDBResult<()> {
2724    ensure_event_target_queue(runtime, queue)
2725}
2726
2727fn ensure_event_target_queue(runtime: &RedDBRuntime, queue: &str) -> RedDBResult<()> {
2728    let store = runtime.inner.db.store();
2729    if store.get_collection(queue).is_some() {
2730        return Ok(());
2731    }
2732    store
2733        .create_collection(queue)
2734        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2735    runtime
2736        .inner
2737        .db
2738        .save_collection_contract(event_queue_collection_contract(queue))
2739        .map_err(|err| RedDBError::Internal(err.to_string()))?;
2740    store.set_config_tree(
2741        &format!("queue.{queue}.mode"),
2742        &crate::serde_json::Value::String("fanout".to_string()),
2743    );
2744    Ok(())
2745}
2746
2747fn event_queue_collection_contract(queue: &str) -> crate::physical::CollectionContract {
2748    let now = current_unix_ms();
2749    crate::physical::CollectionContract {
2750        name: queue.to_string(),
2751        declared_model: crate::catalog::CollectionModel::Queue,
2752        schema_mode: crate::catalog::SchemaMode::Dynamic,
2753        origin: crate::physical::ContractOrigin::Implicit,
2754        version: 1,
2755        created_at_unix_ms: now,
2756        updated_at_unix_ms: now,
2757        default_ttl_ms: None,
2758        vector_dimension: None,
2759        vector_metric: None,
2760        context_index_fields: Vec::new(),
2761        declared_columns: Vec::new(),
2762        table_def: None,
2763        timestamps_enabled: false,
2764        context_index_enabled: false,
2765        metrics_raw_retention_ms: None,
2766        metrics_rollup_policies: Vec::new(),
2767        metrics_tenant_identity: None,
2768        metrics_namespace: None,
2769        append_only: true,
2770        subscriptions: Vec::new(),
2771        analytics_config: Vec::new(),
2772        session_key: None,
2773        session_gap_ms: None,
2774        retention_duration_ms: None,
2775        analytical_storage: None,
2776
2777        ai_policy: None,
2778    }
2779}
2780
2781fn build_table_def_from_create_table(
2782    query: &CreateTableQuery,
2783) -> RedDBResult<crate::storage::schema::TableDef> {
2784    let mut table = crate::storage::schema::TableDef::new(query.name.clone());
2785    for column in &query.columns {
2786        if column.primary_key {
2787            table.primary_key.push(column.name.clone());
2788            table.constraints.push(
2789                crate::storage::schema::Constraint::new(
2790                    format!("pk_{}", column.name),
2791                    crate::storage::schema::ConstraintType::PrimaryKey,
2792                )
2793                .on_columns(vec![column.name.clone()]),
2794            );
2795        }
2796        if column.unique {
2797            table.constraints.push(
2798                crate::storage::schema::Constraint::new(
2799                    format!("uniq_{}", column.name),
2800                    crate::storage::schema::ConstraintType::Unique,
2801                )
2802                .on_columns(vec![column.name.clone()]),
2803            );
2804        }
2805        if column.not_null {
2806            table.constraints.push(
2807                crate::storage::schema::Constraint::new(
2808                    format!("not_null_{}", column.name),
2809                    crate::storage::schema::ConstraintType::NotNull,
2810                )
2811                .on_columns(vec![column.name.clone()]),
2812            );
2813        }
2814        table.columns.push(column_def_from_ddl(column)?);
2815    }
2816    // WITH timestamps = true: append the two runtime-managed columns
2817    // to the schema so resolved_contract_columns exposes them to the
2818    // normalize/validate path. Declared as UnsignedInteger (unix-ms),
2819    // not-nullable; the write path auto-fills them.
2820    if query.timestamps {
2821        table.columns.push(
2822            crate::storage::schema::ColumnDef::new(
2823                "created_at".to_string(),
2824                crate::storage::schema::DataType::UnsignedInteger,
2825            )
2826            .not_null(),
2827        );
2828        table.columns.push(
2829            crate::storage::schema::ColumnDef::new(
2830                "updated_at".to_string(),
2831                crate::storage::schema::DataType::UnsignedInteger,
2832            )
2833            .not_null(),
2834        );
2835        table.constraints.push(
2836            crate::storage::schema::Constraint::new(
2837                "not_null_created_at".to_string(),
2838                crate::storage::schema::ConstraintType::NotNull,
2839            )
2840            .on_columns(vec!["created_at".to_string()]),
2841        );
2842        table.constraints.push(
2843            crate::storage::schema::Constraint::new(
2844                "not_null_updated_at".to_string(),
2845                crate::storage::schema::ConstraintType::NotNull,
2846            )
2847            .on_columns(vec!["updated_at".to_string()]),
2848        );
2849    }
2850    table
2851        .validate()
2852        .map_err(|err| RedDBError::Query(format!("invalid table definition: {err}")))?;
2853    Ok(table)
2854}
2855
2856fn column_def_from_ddl(column: &CreateColumnDef) -> RedDBResult<crate::storage::schema::ColumnDef> {
2857    let data_type = resolve_declared_data_type(&column.data_type)
2858        .map_err(|err| RedDBError::Query(err.to_string()))?;
2859    let mut column_def = crate::storage::schema::ColumnDef::new(column.name.clone(), data_type);
2860    if column.not_null {
2861        column_def = column_def.not_null();
2862    }
2863    if let Some(default) = &column.default {
2864        column_def = column_def.with_default(default.as_bytes().to_vec());
2865    }
2866    if column.compress.unwrap_or(0) > 0 {
2867        column_def = column_def.compressed();
2868    }
2869    if !column.enum_variants.is_empty() {
2870        column_def = column_def.with_variants(column.enum_variants.clone());
2871    }
2872    if let Some(precision) = column.decimal_precision {
2873        column_def = column_def.with_precision(precision);
2874    }
2875    if let Some(element_type) = &column.array_element {
2876        column_def = column_def.with_element_type(
2877            resolve_declared_data_type(element_type)
2878                .map_err(|err| RedDBError::Query(err.to_string()))?,
2879        );
2880    }
2881    column_def = column_def.with_metadata("ddl_data_type", column.data_type.clone());
2882    if column.unique {
2883        column_def = column_def.with_metadata("unique", "true");
2884    }
2885    if column.primary_key {
2886        column_def = column_def.with_metadata("primary_key", "true");
2887    }
2888    Ok(column_def)
2889}
2890
2891fn current_unix_ms() -> u128 {
2892    std::time::SystemTime::now()
2893        .duration_since(std::time::UNIX_EPOCH)
2894        .unwrap_or_default()
2895        .as_millis()
2896}
2897
2898#[cfg(test)]
2899mod tests {
2900    use crate::auth::policies::{ActionPattern, Effect, Policy, ResourcePattern, Statement};
2901    use crate::auth::store::{AuthStore, PrincipalRef};
2902    use crate::auth::UserId;
2903    use crate::auth::{AuthConfig, Role};
2904    use crate::runtime::impl_core::{clear_current_auth_identity, set_current_auth_identity};
2905    use crate::storage::schema::Value;
2906    use crate::{RedDBOptions, RedDBRuntime};
2907    use std::sync::Arc;
2908
2909    fn make_allow_policy(id: &str, action: &str, collection: &str) -> Policy {
2910        Policy {
2911            id: id.to_string(),
2912            version: 1,
2913            tenant: None,
2914            created_at: 0,
2915            updated_at: 0,
2916            statements: vec![Statement {
2917                sid: None,
2918                effect: Effect::Allow,
2919                actions: vec![ActionPattern::Exact(action.to_string())],
2920                resources: vec![ResourcePattern::Exact {
2921                    kind: "collection".to_string(),
2922                    name: collection.to_string(),
2923                }],
2924                condition: None,
2925            }],
2926        }
2927    }
2928
2929    fn wire_auth_store(rt: &RedDBRuntime) -> Arc<AuthStore> {
2930        let store = Arc::new(AuthStore::new(AuthConfig::default()));
2931        *rt.inner.auth_store.write() = Some(store.clone());
2932        store
2933    }
2934
2935    #[test]
2936    fn drop_denied_without_iam_policy() {
2937        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2938        rt.execute_query("CREATE TABLE foo (id INT)").unwrap();
2939        let store = wire_auth_store(&rt);
2940        // Put a select-only policy so IAM mode activates, but give alice no drop policy.
2941        let select_only = Policy {
2942            id: "select-only".to_string(),
2943            version: 1,
2944            tenant: None,
2945            created_at: 0,
2946            updated_at: 0,
2947            statements: vec![Statement {
2948                sid: None,
2949                effect: Effect::Allow,
2950                actions: vec![ActionPattern::Exact("select".to_string())],
2951                resources: vec![ResourcePattern::Wildcard],
2952                condition: None,
2953            }],
2954        };
2955        store.put_policy_internal(select_only).unwrap();
2956        let alice = UserId::from_parts(None, "alice");
2957        store
2958            .attach_policy(PrincipalRef::User(alice), "select-only")
2959            .unwrap();
2960        set_current_auth_identity("alice".to_string(), Role::Write);
2961        let err = rt.execute_query("DROP TABLE foo").unwrap_err();
2962        clear_current_auth_identity();
2963        assert!(
2964            format!("{err}").contains("denied by IAM policy"),
2965            "got: {err}"
2966        );
2967    }
2968
2969    #[test]
2970    fn drop_allowed_with_explicit_iam_policy() {
2971        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2972        rt.execute_query("CREATE TABLE bar (id INT)").unwrap();
2973        let store = wire_auth_store(&rt);
2974        let policy = make_allow_policy("allow-drop-bar", "drop", "bar");
2975        store.put_policy_internal(policy).unwrap();
2976        let bob = UserId::from_parts(None, "bob");
2977        store
2978            .attach_policy(PrincipalRef::User(bob), "allow-drop-bar")
2979            .unwrap();
2980        set_current_auth_identity("bob".to_string(), Role::Write);
2981        rt.execute_query("DROP TABLE bar").unwrap();
2982        clear_current_auth_identity();
2983    }
2984
2985    #[test]
2986    fn drop_allowed_with_wildcard_iam_policy() {
2987        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2988        rt.execute_query("CREATE TABLE baz (id INT)").unwrap();
2989        let store = wire_auth_store(&rt);
2990        let policy = Policy {
2991            id: "allow-drop-all".to_string(),
2992            version: 1,
2993            tenant: None,
2994            created_at: 0,
2995            updated_at: 0,
2996            statements: vec![Statement {
2997                sid: None,
2998                effect: Effect::Allow,
2999                actions: vec![ActionPattern::Exact("drop".to_string())],
3000                resources: vec![ResourcePattern::Wildcard],
3001                condition: None,
3002            }],
3003        };
3004        store.put_policy_internal(policy).unwrap();
3005        let carl = UserId::from_parts(None, "carl");
3006        store
3007            .attach_policy(PrincipalRef::User(carl), "allow-drop-all")
3008            .unwrap();
3009        set_current_auth_identity("carl".to_string(), Role::Write);
3010        rt.execute_query("DROP TABLE baz").unwrap();
3011        clear_current_auth_identity();
3012    }
3013
3014    #[test]
3015    fn truncate_denied_without_iam_policy() {
3016        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3017        rt.execute_query("CREATE TABLE qux (id INT)").unwrap();
3018        let store = wire_auth_store(&rt);
3019        // Acceptance #2 (#712 / S5A): in `policy_only` mode a
3020        // principal with no matching policy is denied even if their
3021        // role would have permitted the action. The pre-#712 default
3022        // of "no policy → deny" only holds under `policy_only`, so
3023        // pin the mode explicitly here so the assertion holds
3024        // regardless of the construction-time default.
3025        store
3026            .set_enforcement_mode(crate::auth::enforcement_mode::PolicyEnforcementMode::PolicyOnly);
3027        // A policy exists (IAM active) but gives no truncate right.
3028        let select_only = Policy {
3029            id: "select-only-2".to_string(),
3030            version: 1,
3031            tenant: None,
3032            created_at: 0,
3033            updated_at: 0,
3034            statements: vec![Statement {
3035                sid: None,
3036                effect: Effect::Allow,
3037                actions: vec![ActionPattern::Exact("select".to_string())],
3038                resources: vec![ResourcePattern::Wildcard],
3039                condition: None,
3040            }],
3041        };
3042        store.put_policy_internal(select_only).unwrap();
3043        let dana = UserId::from_parts(None, "dana");
3044        store
3045            .attach_policy(PrincipalRef::User(dana), "select-only-2")
3046            .unwrap();
3047        set_current_auth_identity("dana".to_string(), Role::Write);
3048        let err = rt.execute_query("TRUNCATE TABLE qux").unwrap_err();
3049        clear_current_auth_identity();
3050        assert!(
3051            format!("{err}").contains("denied by IAM policy"),
3052            "got: {err}"
3053        );
3054    }
3055
3056    #[test]
3057    fn truncate_table_clears_rows_and_preserves_schema_and_indexes() {
3058        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3059        rt.execute_query("CREATE TABLE users (id INT, name TEXT)")
3060            .unwrap();
3061        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'ana'), (2, 'bob')")
3062            .unwrap();
3063        rt.execute_query("CREATE INDEX idx_users_id ON users (id) USING HASH")
3064            .unwrap();
3065
3066        let truncated = rt.execute_query("TRUNCATE TABLE users").unwrap();
3067        assert_eq!(truncated.statement_type, "truncate");
3068        assert_eq!(truncated.affected_rows, 0);
3069
3070        let empty = rt.execute_query("SELECT id FROM users").unwrap();
3071        assert!(empty.result.records.is_empty());
3072
3073        rt.execute_query("INSERT INTO users (id, name) VALUES (3, 'cy')")
3074            .unwrap();
3075        let selected = rt
3076            .execute_query("SELECT name FROM users WHERE id = 3")
3077            .unwrap();
3078        let name = selected.result.records[0].get("name").unwrap();
3079        assert_eq!(name, &Value::text("cy"));
3080        assert!(rt.db().collection_contract("users").is_some());
3081        assert!(rt
3082            .inner
3083            .index_store
3084            .list_indices("users")
3085            .iter()
3086            .any(|index| index.name == "idx_users_id"));
3087    }
3088
3089    #[test]
3090    fn truncate_collection_is_polymorphic_and_typed_mismatch_fails() {
3091        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3092        rt.execute_query("CREATE QUEUE tasks").unwrap();
3093        rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3094
3095        let err = rt.execute_query("TRUNCATE TABLE tasks").unwrap_err();
3096        assert!(format!("{err}").contains("model mismatch: expected table, got queue"));
3097
3098        rt.execute_query("TRUNCATE COLLECTION tasks").unwrap();
3099        let len = rt.execute_query("QUEUE LEN tasks").unwrap();
3100        assert_eq!(
3101            len.result.records[0].get("len"),
3102            Some(&Value::UnsignedInteger(0))
3103        );
3104    }
3105
3106    #[test]
3107    fn truncate_system_schema_is_read_only() {
3108        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3109        let err = rt
3110            .execute_query("TRUNCATE COLLECTION red.collections")
3111            .unwrap_err();
3112        assert!(format!("{err}").contains("system schema is read-only"));
3113    }
3114
3115    // ── #302 / #310: TRUNCATE / DROP single-event semantics ────────────────
3116
3117    fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3118        let result = rt
3119            .execute_query(&format!("QUEUE PEEK {queue} 100"))
3120            .expect("peek queue");
3121        result
3122            .result
3123            .records
3124            .iter()
3125            .map(
3126                |record| match record.get("payload").expect("payload column") {
3127                    Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3128                    other => panic!("expected JSON queue payload, got {other:?}"),
3129                },
3130            )
3131            .collect()
3132    }
3133
3134    /// `TRUNCATE users` on an event-enabled collection emits exactly 1
3135    /// `truncate` event, not one delete event per row.
3136    #[test]
3137    fn truncate_event_enabled_table_emits_single_truncate_event() {
3138        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3139        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3140            .unwrap();
3141        rt.execute_query(
3142            "INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')",
3143        )
3144        .unwrap();
3145
3146        // Drain the 3 insert events so we start clean.
3147        rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3148
3149        rt.execute_query("TRUNCATE TABLE users").unwrap();
3150
3151        let events = queue_payloads(&rt, "users_events");
3152        // Must be exactly 1 truncate event, not 3 delete events.
3153        assert_eq!(
3154            events.len(),
3155            1,
3156            "expected 1 truncate event, got {}",
3157            events.len()
3158        );
3159        let ev = events[0].as_object().expect("event is object");
3160        assert_eq!(
3161            ev.get("op").and_then(crate::json::Value::as_str),
3162            Some("truncate")
3163        );
3164        assert_eq!(
3165            ev.get("collection").and_then(crate::json::Value::as_str),
3166            Some("users")
3167        );
3168        assert_eq!(
3169            ev.get("entities_count")
3170                .and_then(crate::json::Value::as_u64),
3171            Some(3)
3172        );
3173        assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3174        assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3175        assert!(ev
3176            .get("event_id")
3177            .and_then(crate::json::Value::as_str)
3178            .is_some_and(|s| !s.is_empty()));
3179    }
3180
3181    /// `TRUNCATE users` on a collection without event subscription emits no events.
3182    #[test]
3183    fn truncate_no_events_collection_emits_nothing() {
3184        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3185        rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3186            .unwrap();
3187        rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a'), (2, 'b')")
3188            .unwrap();
3189        // No EVENTS subscription — truncate must work without touching any queue.
3190        rt.execute_query("TRUNCATE TABLE plain").unwrap();
3191        // No crash, no queue to check. Just verify truncation happened.
3192        let rows = rt.execute_query("SELECT id FROM plain").unwrap();
3193        assert!(rows.result.records.is_empty());
3194    }
3195
3196    /// `DROP TABLE users` on an event-enabled collection emits exactly 1
3197    /// `collection_dropped` event. The subscription is removed from the
3198    /// source contract but the target queue is preserved for consumer drain.
3199    #[test]
3200    fn drop_event_enabled_table_emits_single_collection_dropped_event() {
3201        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3202        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3203            .unwrap();
3204        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob')")
3205            .unwrap();
3206
3207        // Drain insert events so we start clean.
3208        rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3209
3210        rt.execute_query("DROP TABLE users").unwrap();
3211
3212        // Queue must still exist with 1 collection_dropped event.
3213        let events = queue_payloads(&rt, "users_events");
3214        assert_eq!(
3215            events.len(),
3216            1,
3217            "expected 1 collection_dropped event, got {}",
3218            events.len()
3219        );
3220        let ev = events[0].as_object().expect("event is object");
3221        assert_eq!(
3222            ev.get("op").and_then(crate::json::Value::as_str),
3223            Some("collection_dropped")
3224        );
3225        assert_eq!(
3226            ev.get("collection").and_then(crate::json::Value::as_str),
3227            Some("users")
3228        );
3229        assert_eq!(
3230            ev.get("final_entities_count")
3231                .and_then(crate::json::Value::as_u64),
3232            Some(2)
3233        );
3234        assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3235        assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3236        assert!(ev
3237            .get("event_id")
3238            .and_then(crate::json::Value::as_str)
3239            .is_some_and(|s| !s.is_empty()));
3240
3241        // Source collection is gone.
3242        let err = rt.execute_query("SELECT id FROM users").unwrap_err();
3243        assert!(
3244            format!("{err}").contains("users"),
3245            "expected not-found error"
3246        );
3247    }
3248
3249    /// `DROP TABLE users` on a collection without event subscription works
3250    /// normally with no event emitted.
3251    #[test]
3252    fn drop_no_events_collection_emits_nothing() {
3253        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3254        rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3255            .unwrap();
3256        rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a')")
3257            .unwrap();
3258        rt.execute_query("DROP TABLE plain").unwrap();
3259        // No crash and collection is gone.
3260        let err = rt.execute_query("SELECT id FROM plain").unwrap_err();
3261        assert!(format!("{err}").contains("plain"));
3262    }
3263
3264    // ── #297: ops_filter + WHERE filter ────────────────────────────────────
3265
3266    /// `WITH EVENTS (INSERT)` — UPDATE and DELETE events must NOT be emitted.
3267    #[test]
3268    fn ops_filter_insert_only_ignores_update_and_delete() {
3269        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3270        rt.execute_query(
3271            "CREATE TABLE items (id INT, val TEXT) WITH EVENTS (INSERT) TO items_events",
3272        )
3273        .unwrap();
3274        rt.execute_query("INSERT INTO items (id, val) VALUES (1, 'a')")
3275            .unwrap();
3276        rt.execute_query("UPDATE items SET val = 'b' WHERE id = 1")
3277            .unwrap();
3278        rt.execute_query("DELETE FROM items WHERE id = 1").unwrap();
3279
3280        let events = queue_payloads(&rt, "items_events");
3281        // Only the INSERT should have fired.
3282        assert_eq!(
3283            events.len(),
3284            1,
3285            "expected 1 insert event, got {}",
3286            events.len()
3287        );
3288        assert_eq!(
3289            events[0]
3290                .as_object()
3291                .unwrap()
3292                .get("op")
3293                .and_then(crate::json::Value::as_str),
3294            Some("insert")
3295        );
3296    }
3297
3298    /// `WITH EVENTS WHERE status = 'active'` — only rows matching the predicate generate events.
3299    #[test]
3300    fn where_filter_skips_rows_that_do_not_match() {
3301        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3302        rt.execute_query(
3303            "CREATE TABLE users (id INT, status TEXT) WITH EVENTS WHERE status = 'active' TO users_events",
3304        )
3305        .unwrap();
3306
3307        // This row should generate an event.
3308        rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active')")
3309            .unwrap();
3310        // This row should NOT generate an event.
3311        rt.execute_query("INSERT INTO users (id, status) VALUES (2, 'inactive')")
3312            .unwrap();
3313
3314        let events = queue_payloads(&rt, "users_events");
3315        assert_eq!(
3316            events.len(),
3317            1,
3318            "expected 1 event (only active), got {}",
3319            events.len()
3320        );
3321        let ev = events[0].as_object().unwrap();
3322        assert_eq!(
3323            ev.get("op").and_then(crate::json::Value::as_str),
3324            Some("insert")
3325        );
3326        let after = ev.get("after").unwrap().as_object().unwrap();
3327        assert_eq!(
3328            after.get("status").and_then(crate::json::Value::as_str),
3329            Some("active")
3330        );
3331    }
3332
3333    /// `WITH EVENTS (INSERT, UPDATE) WHERE status = 'active'` — combination functional.
3334    #[test]
3335    fn ops_filter_and_where_filter_combined() {
3336        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3337        rt.execute_query(
3338            "CREATE TABLE items (id INT, status TEXT) WITH EVENTS (INSERT, UPDATE) WHERE status = 'active' TO items_events",
3339        )
3340        .unwrap();
3341
3342        // INSERT active → event
3343        rt.execute_query("INSERT INTO items (id, status) VALUES (1, 'active')")
3344            .unwrap();
3345        // INSERT inactive → no event
3346        rt.execute_query("INSERT INTO items (id, status) VALUES (2, 'inactive')")
3347            .unwrap();
3348        // UPDATE row 1 to inactive → after = inactive, no event
3349        rt.execute_query("UPDATE items SET status = 'inactive' WHERE id = 1")
3350            .unwrap();
3351        // DELETE → ops_filter excludes it
3352        rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3353
3354        let events = queue_payloads(&rt, "items_events");
3355        // Only the first INSERT (active) fires; UPDATE result is inactive so skipped; DELETE excluded by ops_filter.
3356        assert_eq!(
3357            events.len(),
3358            1,
3359            "expected 1 event, got {}: {events:?}",
3360            events.len()
3361        );
3362        assert_eq!(
3363            events[0]
3364                .as_object()
3365                .unwrap()
3366                .get("op")
3367                .and_then(crate::json::Value::as_str),
3368            Some("insert")
3369        );
3370    }
3371
3372    /// WHERE filter on DELETE events — the before-state (pre-image) is evaluated.
3373    #[test]
3374    fn where_filter_on_delete_checks_before_state() {
3375        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3376        rt.execute_query(
3377            "CREATE TABLE users (id INT, status TEXT) WITH EVENTS (DELETE) WHERE status = 'active' TO users_events",
3378        )
3379        .unwrap();
3380
3381        rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active'), (2, 'inactive')")
3382            .unwrap();
3383
3384        // Delete active row → event (before-state was active)
3385        rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3386        // Delete inactive row → no event (before-state was inactive)
3387        rt.execute_query("DELETE FROM users WHERE id = 2").unwrap();
3388
3389        let events = queue_payloads(&rt, "users_events");
3390        assert_eq!(
3391            events.len(),
3392            1,
3393            "expected 1 delete event, got {}",
3394            events.len()
3395        );
3396        let ev = events[0].as_object().unwrap();
3397        assert_eq!(
3398            ev.get("op").and_then(crate::json::Value::as_str),
3399            Some("delete")
3400        );
3401    }
3402
3403    // ── #301: schema evolution OperatorEvent on ALTER ───────────────────────
3404
3405    /// ADD COLUMN on event-enabled table must succeed (OperatorEvent is best-effort).
3406    #[test]
3407    fn alter_add_column_on_event_enabled_table_succeeds() {
3408        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3409        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3410            .unwrap();
3411        // Must not error — OperatorEvent emission is best-effort (no global sink in tests).
3412        rt.execute_query("ALTER TABLE users ADD COLUMN phone TEXT")
3413            .unwrap();
3414        // The column is now in the contract.
3415        let contract = rt.db().collection_contract("users").unwrap();
3416        assert!(
3417            contract.declared_columns.iter().any(|c| c.name == "phone"),
3418            "phone column should be in contract"
3419        );
3420        // Subscription still enabled after the alter.
3421        assert!(
3422            contract.subscriptions.iter().any(|s| s.enabled),
3423            "subscription should remain enabled"
3424        );
3425    }
3426
3427    /// DROP COLUMN on event-enabled table must succeed; non-column ALTERs
3428    /// (like ENABLE ROW LEVEL SECURITY) must also succeed without emitting.
3429    #[test]
3430    fn alter_drop_column_and_rls_on_event_enabled_table_succeeds() {
3431        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3432        rt.execute_query(
3433            "CREATE TABLE items (id INT, secret TEXT, status TEXT) WITH EVENTS TO items_events",
3434        )
3435        .unwrap();
3436        // DROP COLUMN — schema change event path exercises, must not error.
3437        rt.execute_query("ALTER TABLE items DROP COLUMN secret")
3438            .unwrap();
3439        let contract = rt.db().collection_contract("items").unwrap();
3440        assert!(
3441            !contract.declared_columns.iter().any(|c| c.name == "secret"),
3442            "secret column should be removed"
3443        );
3444        // ENABLE RLS — non-column op, no schema-change event (coverage).
3445        rt.execute_query("ALTER TABLE items ENABLE ROW LEVEL SECURITY")
3446            .unwrap();
3447        // Collection and subscription still intact.
3448        assert!(
3449            contract.subscriptions.iter().any(|s| s.enabled),
3450            "subscription should remain enabled"
3451        );
3452    }
3453
3454    /// Slice G (#675) — every newly-created `CollectionModel::Vector`
3455    /// collection is marked `vector.turbo` and gains a materialised
3456    /// `TurboCollectionState`. The marker is what the SEARCH/INSERT
3457    /// hot paths read to branch between TurboQuant and the legacy
3458    /// brute-force fallback.
3459    #[test]
3460    fn create_vector_marks_collection_as_turbo_baseline() {
3461        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3462        rt.execute_query("CREATE VECTOR embeddings DIM 4").unwrap();
3463        let store = rt.db().store();
3464        assert!(
3465            crate::runtime::vector_turbo_kind::is_turbo(&store, "embeddings"),
3466            "new vector collections must be turbo-marked baseline"
3467        );
3468        assert!(
3469            rt.db().turbo_state("embeddings").is_some(),
3470            "turbo_state must materialise after CREATE VECTOR"
3471        );
3472    }
3473
3474    /// Non-vector collections must NOT be turbo-marked. Guards against
3475    /// the always-baseline change accidentally leaking the marker onto
3476    /// CREATE TABLE / CREATE DOCUMENT / etc.
3477    #[test]
3478    fn create_table_does_not_mark_turbo() {
3479        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3480        rt.execute_query("CREATE TABLE plain (id INT)").unwrap();
3481        let store = rt.db().store();
3482        assert!(
3483            !crate::runtime::vector_turbo_kind::is_turbo(&store, "plain"),
3484            "non-vector collections must not gain the turbo marker"
3485        );
3486        assert!(rt.db().turbo_state("plain").is_none());
3487    }
3488
3489    /// `CREATE COLLECTION KIND vector.turbo` continues to mark the
3490    /// collection (idempotent with the new always-baseline path).
3491    #[test]
3492    fn create_collection_kind_vector_turbo_still_marked() {
3493        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3494        rt.execute_query("CREATE COLLECTION turbo_v KIND vector.turbo DIM 4")
3495            .unwrap();
3496        let store = rt.db().store();
3497        assert!(crate::runtime::vector_turbo_kind::is_turbo(
3498            &store, "turbo_v"
3499        ));
3500        assert!(rt.db().turbo_state("turbo_v").is_some());
3501    }
3502
3503    /// Issue #1271: a declared AI policy is persisted in the catalog and
3504    /// reads back identical via the collection contract (introspection).
3505    #[test]
3506    fn create_table_persists_and_introspects_ai_policy() {
3507        use crate::catalog::{ModerateDegradedMode, ModerateRejectAction};
3508        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3509        rt.execute_query(
3510            "CREATE TABLE posts (id INT, title TEXT, body TEXT, photo TEXT) WITH ( \
3511               EMBED (fields = ('title', 'body'), provider = 'openai', model = 'text-embedding-3-small'), \
3512               MODERATE (fields = ('body'), provider = 'openai', model = 'omni-moderation-latest', sync = true, degraded = closed, on_reject = flag), \
3513               VISION (image_field = 'photo', outputs = ('caption'), provider = 'openai', model = 'gpt-4o') \
3514             )",
3515        )
3516        .expect("create table with ai policy");
3517
3518        let contracts = rt.db().collection_contracts();
3519        let contract = contracts
3520            .iter()
3521            .find(|c| c.name == "posts")
3522            .expect("contract persisted");
3523        let policy = contract.ai_policy.as_ref().expect("ai policy persisted");
3524
3525        let embed = policy.embed.as_ref().expect("embed");
3526        assert_eq!(embed.fields, vec!["title".to_string(), "body".to_string()]);
3527        assert_eq!(embed.model, "text-embedding-3-small");
3528
3529        let moderate = policy.moderate.as_ref().expect("moderate");
3530        assert!(moderate.sync_gate);
3531        assert_eq!(moderate.degraded_mode, ModerateDegradedMode::Closed);
3532        assert_eq!(moderate.reject_action, ModerateRejectAction::Flag);
3533
3534        let vision = policy.vision.as_ref().expect("vision");
3535        assert_eq!(vision.image_field, "photo");
3536        assert_eq!(vision.model, "gpt-4o");
3537    }
3538
3539    /// Issue #1271 / #1269: a policy wiring a provider to a modality it
3540    /// cannot serve is rejected at DDL time, and the collection is not
3541    /// created. Anthropic has no embeddings product in the matrix.
3542    #[test]
3543    fn create_table_rejects_ai_policy_incapable_provider() {
3544        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3545        let err = rt
3546            .execute_query(
3547                "CREATE TABLE bad (id INT, body TEXT) WITH ( \
3548                   EMBED (fields = ('body'), provider = 'anthropic', model = 'claude-3-5-sonnet') \
3549                 )",
3550            )
3551            .unwrap_err();
3552        assert!(
3553            err.to_string()
3554                .contains("cannot serve the 'embed' modality"),
3555            "{err}"
3556        );
3557        assert!(
3558            rt.db().store().get_collection("bad").is_none(),
3559            "rejected DDL must not create the collection"
3560        );
3561    }
3562}