1use super::*;
9use crate::catalog::CollectionModel;
10use crate::runtime::audit_log::{AuditAuthSource, AuditEvent, AuditFieldEscaper, Outcome};
11use crate::runtime::ddl::polymorphic_resolver;
12use reddb_engine::catalog::ddl::declared_column_contract_from_ddl;
13use reddb_rql::analyzer::analyze_create_table;
14use reddb_rql::ast::CreateColumnDef;
15use reddb_rql::ast::{
16 CreateVcsRefQuery, DropForkQuery, DropVcsRefQuery, ForkStoreQuery, VcsRefKind,
17};
18use std::collections::{BTreeSet, HashMap, HashSet};
19
20fn vault_master_key_ref(collection: &str) -> String {
21 format!("red.vault.{collection}.master_key")
22}
23
24impl RedDBRuntime {
25 pub fn execute_create_table(
31 &self,
32 raw_query: &str,
33 query: &CreateTableQuery,
34 ) -> RedDBResult<RuntimeQueryResult> {
35 if query.collection_model != CollectionModel::Table {
36 if query
37 .columns
38 .iter()
39 .any(|column| column.generated.is_some() || column.check.is_some())
40 {
41 return Err(RedDBError::Query(
42 "schema expressions currently require TABLE collections".to_string(),
43 ));
44 }
45 return self.execute_create_keyed_collection(raw_query, query);
46 }
47 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
48 if is_system_schema_name(&query.name) {
49 return Err(RedDBError::Query("system schema is read-only".to_string()));
50 }
51 let store = self.inner.db.store();
52 analyze_create_table(query).map_err(|err| RedDBError::Query(err.to_string()))?;
53 if let Some(policy) = &query.ai_policy {
57 validate_ai_policy_modalities(policy)?;
58 }
59 crate::reserved_fields::ensure_no_reserved_public_item_fields(
60 query.columns.iter().map(|column| column.name.as_str()),
61 &format!("table '{}'", query.name),
62 )?;
63 let exists = store.get_collection(&query.name).is_some();
65 if exists {
66 if query.if_not_exists {
67 return Ok(RuntimeQueryResult::ok_message(
68 raw_query.to_string(),
69 &format!("table '{}' already exists", query.name),
70 "create",
71 ));
72 }
73 return Err(RedDBError::Query(format!(
74 "table '{}' already exists",
75 query.name
76 )));
77 }
78
79 let contract = collection_contract_from_create_table(query)?;
82 crate::application::collection_contract_enforcer::validate_contract_expressions(&contract)?;
83 validate_event_subscriptions(self, &query.name, &contract.subscriptions)?;
84 store
86 .create_collection(&query.name)
87 .map_err(|err| RedDBError::Internal(err.to_string()))?;
88 for subscription in &contract.subscriptions {
89 ensure_event_target_queue(self, &subscription.target_queue)?;
90 }
91 if let Some(default_ttl_ms) = query.default_ttl_ms {
92 self.inner
93 .db
94 .set_collection_default_ttl_ms(&query.name, default_ttl_ms);
95 }
96 self.inner
97 .db
98 .save_collection_contract(contract)
99 .map_err(|err| RedDBError::Internal(err.to_string()))?;
100 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
101 store.set_config_tree(
102 &format!("collection_tenants.{}", query.name),
103 &crate::serde_json::Value::String(tenant_id),
104 );
105 }
106 self.inner
107 .db
108 .persist_metadata()
109 .map_err(|err| RedDBError::Internal(err.to_string()))?;
110 self.refresh_table_planner_stats(&query.name);
111 self.invalidate_result_cache();
112 let columns: Vec<String> = query.columns.iter().map(|col| col.name.clone()).collect();
115 self.schema_vocabulary_apply(
116 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
117 collection: query.name.clone(),
118 columns,
119 type_tags: Vec::new(),
120 description: query.description.clone(),
121 },
122 );
123 self.schema_vocabulary_sync_descriptions(&query.name);
124 if let Some(spec) = &query.partition_by {
131 let kind_str = match spec.kind {
132 reddb_rql::ast::PartitionKind::Range => "range",
133 reddb_rql::ast::PartitionKind::List => "list",
134 reddb_rql::ast::PartitionKind::Hash => "hash",
135 };
136 store.set_config_tree(
137 &format!("partition.{}.by", query.name),
138 &crate::serde_json::Value::String(kind_str.to_string()),
139 );
140 store.set_config_tree(
141 &format!("partition.{}.column", query.name),
142 &crate::serde_json::Value::String(spec.column.clone()),
143 );
144 }
145
146 if let Some(col) = &query.tenant_by {
157 store.set_config_tree(
158 &format!("tenant_tables.{}.column", query.name),
159 &crate::serde_json::Value::String(col.clone()),
160 );
161 self.register_tenant_table(&query.name, col);
162 }
163
164 let ttl_suffix = query
165 .default_ttl_ms
166 .map(|ttl_ms| format!(" with default TTL {}ms", ttl_ms))
167 .unwrap_or_default();
168
169 let tenant_suffix = query
170 .tenant_by
171 .as_ref()
172 .map(|col| format!(" (tenant-scoped by {col})"))
173 .unwrap_or_default();
174
175 Ok(RuntimeQueryResult::ok_message(
176 raw_query.to_string(),
177 &format!(
178 "table '{}' created{}{}",
179 query.name, ttl_suffix, tenant_suffix
180 ),
181 "create",
182 ))
183 }
184
185 fn execute_create_keyed_collection(
186 &self,
187 raw_query: &str,
188 query: &CreateTableQuery,
189 ) -> RedDBResult<RuntimeQueryResult> {
190 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
191 if is_system_schema_name(&query.name) {
192 return Err(RedDBError::Query("system schema is read-only".to_string()));
193 }
194 let store = self.inner.db.store();
195 let label = polymorphic_resolver::model_name(query.collection_model);
196 if store.get_collection(&query.name).is_some() {
197 if query.if_not_exists {
198 return Ok(RuntimeQueryResult::ok_message(
199 raw_query.to_string(),
200 &format!("{label} '{}' already exists", query.name),
201 "create",
202 ));
203 }
204 return Err(RedDBError::Query(format!(
205 "{label} '{}' already exists",
206 query.name
207 )));
208 }
209
210 if query.collection_model == CollectionModel::Vault {
211 self.reject_vault_transaction()?;
212 self.provision_vault_key_material(&query.name, query.vault_own_master_key)?;
213 }
214 store
215 .create_collection(&query.name)
216 .map_err(|err| RedDBError::Internal(err.to_string()))?;
217 if query.collection_model == CollectionModel::Vault {
218 let key_scope = if query.vault_own_master_key {
219 "own"
220 } else {
221 "cluster"
222 };
223 store.set_config_tree(
224 &format!("vault.{}.key_scope", query.name),
225 &crate::serde_json::Value::String(key_scope.to_string()),
226 );
227 store.set_config_tree(
228 &format!("vault.{}.status", query.name),
229 &crate::serde_json::Value::String("sealed".to_string()),
230 );
231 }
232 if query.collection_model == CollectionModel::Metrics {
233 for spec in &query.metrics_rollup_policies {
234 let policy = crate::storage::timeseries::retention::DownsamplePolicy::parse(spec)
235 .ok_or_else(|| {
236 RedDBError::Query(format!("invalid metrics rollup policy '{}'", spec))
237 })?;
238 if policy.source != "raw" {
239 return Err(RedDBError::Query(format!(
240 "invalid metrics rollup policy '{}': metrics v0 rollups must use raw as source",
241 spec
242 )));
243 }
244 if !matches!(
245 policy.aggregation.as_str(),
246 "avg" | "sum" | "min" | "max" | "count"
247 ) {
248 return Err(RedDBError::Query(format!(
249 "invalid metrics rollup policy '{}': supported aggregations are avg, sum, min, max, count",
250 spec
251 )));
252 }
253 }
254 if let Some(raw_retention_ms) = query.default_ttl_ms {
255 self.inner
256 .db
257 .set_collection_default_ttl_ms(&query.name, raw_retention_ms);
258 store.set_config_tree(
259 &format!("metrics.{}.raw_retention_ms", query.name),
260 &crate::serde_json::Value::Number(raw_retention_ms as f64),
261 );
262 }
263 let tenant_identity = query
264 .tenant_by
265 .clone()
266 .unwrap_or_else(|| "current_tenant".to_string());
267 store.set_config_tree(
268 &format!("metrics.{}.tenant_identity", query.name),
269 &crate::serde_json::Value::String(tenant_identity),
270 );
271 store.set_config_tree(
272 &format!("metrics.{}.namespace", query.name),
273 &crate::serde_json::Value::String("default".to_string()),
274 );
275 if !query.metrics_rollup_policies.is_empty() {
276 store.set_config_tree(
277 &format!("metrics.{}.rollup_policies", query.name),
278 &crate::serde_json::Value::Array(
279 query
280 .metrics_rollup_policies
281 .iter()
282 .cloned()
283 .map(crate::serde_json::Value::String)
284 .collect(),
285 ),
286 );
287 }
288 }
289 let mut contract = if query.collection_model == CollectionModel::Metrics {
290 metrics_collection_contract(query)
291 } else {
292 keyed_collection_contract(
293 &query.name,
294 query.collection_model,
295 query.analytics_config.clone(),
296 )
297 };
298 contract.description = query.description.clone();
299 self.inner
300 .db
301 .save_collection_contract(contract)
302 .map_err(|err| RedDBError::Internal(err.to_string()))?;
303 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
304 store.set_config_tree(
305 &format!("collection_tenants.{}", query.name),
306 &crate::serde_json::Value::String(tenant_id),
307 );
308 }
309 self.inner
310 .db
311 .persist_metadata()
312 .map_err(|err| RedDBError::Internal(err.to_string()))?;
313 self.invalidate_result_cache();
314 self.schema_vocabulary_apply(
315 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
316 collection: query.name.clone(),
317 columns: Vec::new(),
318 type_tags: Vec::new(),
319 description: query.description.clone(),
320 },
321 );
322 self.schema_vocabulary_sync_descriptions(&query.name);
323
324 Ok(RuntimeQueryResult::ok_message(
325 raw_query.to_string(),
326 &format!("{label} '{}' created", query.name),
327 "create",
328 ))
329 }
330
331 pub fn ensure_system_graph_with_analytics(
340 &self,
341 name: &str,
342 outputs: &[crate::catalog::AnalyticsOutput],
343 ) -> RedDBResult<()> {
344 let store = self.inner.db.store();
345 if store.get_collection(name).is_some() {
346 return Ok(());
347 }
348 let analytics_config = outputs
349 .iter()
350 .map(|output| crate::catalog::AnalyticsViewDescriptor {
351 output: *output,
352 algorithm: None,
353 resolution: None,
354 max_iterations: None,
355 tolerance: None,
356 })
357 .collect();
358 store
359 .create_collection(name)
360 .map_err(|err| RedDBError::Internal(err.to_string()))?;
361 let contract = keyed_collection_contract(
362 name,
363 crate::catalog::CollectionModel::Graph,
364 analytics_config,
365 );
366 self.inner
367 .db
368 .save_collection_contract(contract)
369 .map_err(|err| RedDBError::Internal(err.to_string()))?;
370 self.inner
371 .db
372 .persist_metadata()
373 .map_err(|err| RedDBError::Internal(err.to_string()))?;
374 self.invalidate_result_cache_process_only();
375 Ok(())
376 }
377
378 pub fn execute_create_collection(
379 &self,
380 raw_query: &str,
381 query: &CreateCollectionQuery,
382 ) -> RedDBResult<RuntimeQueryResult> {
383 let model = match query.kind.as_str() {
384 "graph" => CollectionModel::Graph,
385 "document" => CollectionModel::Document,
386 "metrics" => CollectionModel::Metrics,
387 "vector.turbo" => {
388 let dimension = query.vector_dimension.ok_or_else(|| {
389 RedDBError::Query(
390 "CREATE COLLECTION KIND vector.turbo requires DIM".to_string(),
391 )
392 })?;
393 let create = CreateVectorQuery {
394 name: query.name.clone(),
395 dimension,
396 metric: query
397 .vector_metric
398 .unwrap_or(crate::storage::engine::distance::DistanceMetric::Cosine),
399 if_not_exists: query.if_not_exists,
400 };
401 let result = self.execute_create_vector(raw_query, &create)?;
402 if let Some(description) = &query.description {
403 if let Some(mut contract) = self.inner.db.collection_contract(&query.name) {
404 contract.description = Some(description.clone());
405 self.inner
406 .db
407 .save_collection_contract(contract)
408 .map_err(|err| RedDBError::Internal(err.to_string()))?;
409 self.schema_vocabulary_sync_descriptions(&query.name);
410 }
411 }
412 let store = self.inner.db.store();
419 crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
420 self.inner
421 .db
422 .persist_metadata()
423 .map_err(|err| RedDBError::Internal(err.to_string()))?;
424 let _ = self.inner.db.turbo_state(&query.name);
429 return Ok(result);
430 }
431 "blockchain" => CollectionModel::Table,
436 other => {
437 return Err(RedDBError::Query(format!(
438 "NOT_YET_SUPPORTED: CREATE COLLECTION KIND {other} is not implemented"
439 )));
440 }
441 };
442 let create = CreateTableQuery {
443 collection_model: model,
444 name: query.name.clone(),
445 description: query.description.clone(),
446 columns: Vec::new(),
447 unique_constraints: Vec::new(),
448 if_not_exists: query.if_not_exists,
449 default_ttl_ms: None,
450 metrics_rollup_policies: Vec::new(),
451 context_index_fields: Vec::new(),
452 context_index_enabled: false,
453 timestamps: false,
454 partition_by: None,
455 tenant_by: None,
456 append_only: false,
457 subscriptions: Vec::new(),
458 analytics_config: Vec::new(),
459 vault_own_master_key: false,
460 ai_policy: None,
461 };
462 let result = self.execute_create_table(raw_query, &create)?;
463 if query.kind == "blockchain" {
464 self.install_blockchain_kind(&query.name)?;
465 }
466 if !query.allowed_signers.is_empty() {
471 let actor = crate::runtime::impl_core::current_user_projected()
472 .unwrap_or_else(|| "@system/create-collection".to_string());
473 crate::runtime::signed_writes_kind::try_install(
474 &self.inner.db.store(),
475 &query.name,
476 &query.allowed_signers,
477 &actor,
478 )?;
479 }
480 Ok(result)
481 }
482
483 fn install_blockchain_kind(&self, name: &str) -> RedDBResult<()> {
487 use crate::runtime::blockchain_kind;
488 use crate::storage::unified::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
489 use std::sync::Arc;
490
491 let store = self.inner.db.store();
492 blockchain_kind::try_mark_as_chain(&store, name)?;
493
494 let existing_tip = blockchain_kind::try_chain_tip(&store, name)?;
495 if existing_tip.height.is_some() {
496 return Ok(());
497 }
498
499 let fields = blockchain_kind::genesis_fields(blockchain_kind::now_ms());
500 let named: std::collections::HashMap<String, reddb_types::Value> =
501 fields.into_iter().collect();
502 let entity = UnifiedEntity::new(
503 EntityId::new(0),
504 EntityKind::TableRow {
505 table: Arc::from(name),
506 row_id: 0,
507 },
508 EntityData::Row(RowData {
509 columns: Vec::new(),
510 named: Some(named),
511 schema: None,
512 }),
513 );
514 store
515 .insert_auto(name, entity)
516 .map_err(|err| RedDBError::Internal(err.to_string()))?;
517 if let Some(tip) = blockchain_kind::try_chain_tip_full(&store, name)? {
520 self.inner
521 .chain_tip_cache
522 .lock()
523 .insert(name.to_string(), tip);
524 }
525 Ok(())
526 }
527
528 pub fn execute_create_vector(
529 &self,
530 raw_query: &str,
531 query: &CreateVectorQuery,
532 ) -> RedDBResult<RuntimeQueryResult> {
533 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
534 if is_system_schema_name(&query.name) {
535 return Err(RedDBError::Query("system schema is read-only".to_string()));
536 }
537 let store = self.inner.db.store();
538 if store.get_collection(&query.name).is_some() {
539 if query.if_not_exists {
540 return Ok(RuntimeQueryResult::ok_message(
541 raw_query.to_string(),
542 &format!("vector '{}' already exists", query.name),
543 "create",
544 ));
545 }
546 return Err(RedDBError::Query(format!(
547 "vector '{}' already exists",
548 query.name
549 )));
550 }
551
552 store
553 .create_collection(&query.name)
554 .map_err(|err| RedDBError::Internal(err.to_string()))?;
555 self.inner
556 .db
557 .save_collection_contract(vector_collection_contract(query))
558 .map_err(|err| RedDBError::Internal(err.to_string()))?;
559 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
560 store.set_config_tree(
561 &format!("collection_tenants.{}", query.name),
562 &crate::serde_json::Value::String(tenant_id),
563 );
564 }
565 crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
574 self.inner
575 .db
576 .persist_metadata()
577 .map_err(|err| RedDBError::Internal(err.to_string()))?;
578 let _ = self.inner.db.turbo_state(&query.name);
583 self.invalidate_result_cache();
584 self.schema_vocabulary_apply(
585 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
586 collection: query.name.clone(),
587 columns: Vec::new(),
588 type_tags: Vec::new(),
589 description: None,
590 },
591 );
592
593 Ok(RuntimeQueryResult::ok_message(
594 raw_query.to_string(),
595 &format!("vector '{}' created", query.name),
596 "create",
597 ))
598 }
599
600 fn provision_vault_key_material(
601 &self,
602 collection: &str,
603 own_master_key: bool,
604 ) -> RedDBResult<()> {
605 let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
606 RedDBError::Query("CREATE VAULT requires an enabled, unsealed vault".to_string())
607 })?;
608 if !auth_store.is_vault_backed() {
609 return Err(RedDBError::Query(
610 "CREATE VAULT requires an enabled, unsealed vault".to_string(),
611 ));
612 }
613
614 auth_store
615 .ensure_vault_secret_key_result()
616 .map_err(|err| RedDBError::Query(err.to_string()))?;
617
618 if own_master_key
619 && auth_store
620 .vault_kv_get(&vault_master_key_ref(collection))
621 .is_none()
622 {
623 let key = crate::auth::store::random_bytes(32);
624 auth_store
625 .vault_kv_try_set(vault_master_key_ref(collection), hex::encode(key))
626 .map_err(|err| RedDBError::Query(err.to_string()))?;
627 }
628
629 Ok(())
630 }
631
632 pub fn execute_drop_table(
636 &self,
637 raw_query: &str,
638 query: &DropTableQuery,
639 ) -> RedDBResult<RuntimeQueryResult> {
640 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
641 let store = self.inner.db.store();
642 let _publication = store.vault_publication_lock.lock();
643 self.vcs_reject_destructive_ddl(&query.name)?;
644
645 if is_system_schema_name(&query.name) {
646 return Err(RedDBError::Query("system schema is read-only".to_string()));
647 }
648
649 let exists = store.get_collection(&query.name).is_some();
650 if !exists {
651 if query.if_exists {
652 return Ok(RuntimeQueryResult::ok_message(
653 raw_query.to_string(),
654 &format!("table '{}' does not exist", query.name),
655 "drop",
656 ));
657 }
658 return Err(RedDBError::NotFound(format!(
659 "table '{}' not found",
660 query.name
661 )));
662 }
663 let actual =
664 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
665 polymorphic_resolver::ensure_model_match(CollectionModel::Table, actual)?;
666
667 let final_count = store
670 .get_collection(&query.name)
671 .map(|manager| manager.query_all(|_| true).len() as u64)
672 .unwrap_or(0);
673 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
674 self,
675 &query.name,
676 final_count,
677 )?;
678
679 let topology_lock = self.inner.index_store.collection_topology_lock(&query.name);
680 let _topology_guard = topology_lock.write();
681 let orphaned_indices: Vec<String> = self
682 .inner
683 .index_store
684 .list_indices(&query.name)
685 .into_iter()
686 .map(|index| index.name)
687 .collect();
688 for name in &orphaned_indices {
689 self.inner.index_store.drop_index(name, &query.name);
690 }
691
692 store
693 .drop_collection(&query.name)
694 .map_err(|err| RedDBError::Internal(err.to_string()))?;
695 self.inner.db.invalidate_vector_index(&query.name);
696 self.inner.db.clear_collection_default_ttl_ms(&query.name);
697 self.inner
698 .db
699 .remove_collection_contract(&query.name)
700 .map_err(|err| RedDBError::Internal(err.to_string()))?;
701 self.clear_table_planner_stats(&query.name);
702 self.invalidate_result_cache();
703 if let Some(store) = self.inner.auth_store.read().clone() {
707 store.invalidate_visible_collections_cache();
708 }
709 self.schema_vocabulary_apply(
718 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
719 collection: query.name.clone(),
720 },
721 );
722
723 Ok(RuntimeQueryResult::ok_message(
724 raw_query.to_string(),
725 &format!("table '{}' dropped", query.name),
726 "drop",
727 ))
728 }
729
730 pub fn execute_drop_graph(
731 &self,
732 raw_query: &str,
733 query: &DropGraphQuery,
734 ) -> RedDBResult<RuntimeQueryResult> {
735 self.execute_drop_typed_collection(
736 raw_query,
737 &query.name,
738 query.if_exists,
739 CollectionModel::Graph,
740 "graph",
741 )
742 }
743
744 pub fn execute_drop_vector(
745 &self,
746 raw_query: &str,
747 query: &DropVectorQuery,
748 ) -> RedDBResult<RuntimeQueryResult> {
749 self.execute_drop_typed_collection(
750 raw_query,
751 &query.name,
752 query.if_exists,
753 CollectionModel::Vector,
754 "vector",
755 )
756 }
757
758 pub fn execute_drop_document(
759 &self,
760 raw_query: &str,
761 query: &DropDocumentQuery,
762 ) -> RedDBResult<RuntimeQueryResult> {
763 self.execute_drop_typed_collection(
764 raw_query,
765 &query.name,
766 query.if_exists,
767 CollectionModel::Document,
768 "document",
769 )
770 }
771
772 pub fn execute_drop_kv(
773 &self,
774 raw_query: &str,
775 query: &DropKvQuery,
776 ) -> RedDBResult<RuntimeQueryResult> {
777 let label = polymorphic_resolver::model_name(query.model);
778 self.execute_drop_typed_collection(
779 raw_query,
780 &query.name,
781 query.if_exists,
782 query.model,
783 label,
784 )
785 }
786
787 pub fn execute_drop_collection(
788 &self,
789 raw_query: &str,
790 query: &DropCollectionQuery,
791 ) -> RedDBResult<RuntimeQueryResult> {
792 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
793 if is_system_schema_name(&query.name) {
794 return Err(RedDBError::Query("system schema is read-only".to_string()));
795 }
796 let store = self.inner.db.store();
797 let _publication = store.vault_publication_lock.lock();
798 self.vcs_reject_destructive_ddl(&query.name)?;
799 if store.get_collection(&query.name).is_none() {
800 if query.if_exists {
801 return Ok(RuntimeQueryResult::ok_message(
802 raw_query.to_string(),
803 &format!("collection '{}' does not exist", query.name),
804 "drop",
805 ));
806 }
807 return Err(RedDBError::NotFound(format!(
808 "collection '{}' not found",
809 query.name
810 )));
811 }
812
813 let actual =
814 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
815 if let Some(expected) = query.model {
816 polymorphic_resolver::ensure_model_match(expected, actual)?;
817 }
818
819 match actual {
820 CollectionModel::Table => self.execute_drop_table(
821 raw_query,
822 &DropTableQuery {
823 name: query.name.clone(),
824 if_exists: query.if_exists,
825 },
826 ),
827 CollectionModel::TimeSeries => self.execute_drop_timeseries(
828 raw_query,
829 &DropTimeSeriesQuery {
830 name: query.name.clone(),
831 if_exists: query.if_exists,
832 },
833 ),
834 CollectionModel::Queue => self.execute_drop_queue(
835 raw_query,
836 &DropQueueQuery {
837 name: query.name.clone(),
838 if_exists: query.if_exists,
839 },
840 ),
841 CollectionModel::Graph => self.execute_drop_graph(
842 raw_query,
843 &DropGraphQuery {
844 name: query.name.clone(),
845 if_exists: query.if_exists,
846 },
847 ),
848 CollectionModel::Vector => self.execute_drop_vector(
849 raw_query,
850 &DropVectorQuery {
851 name: query.name.clone(),
852 if_exists: query.if_exists,
853 },
854 ),
855 CollectionModel::Document => self.execute_drop_document(
856 raw_query,
857 &DropDocumentQuery {
858 name: query.name.clone(),
859 if_exists: query.if_exists,
860 },
861 ),
862 CollectionModel::Kv => self.execute_drop_kv(
863 raw_query,
864 &DropKvQuery {
865 name: query.name.clone(),
866 if_exists: query.if_exists,
867 model: CollectionModel::Kv,
868 },
869 ),
870 CollectionModel::Config => self.execute_drop_kv(
871 raw_query,
872 &DropKvQuery {
873 name: query.name.clone(),
874 if_exists: query.if_exists,
875 model: CollectionModel::Config,
876 },
877 ),
878 CollectionModel::Vault => self.execute_drop_kv(
879 raw_query,
880 &DropKvQuery {
881 name: query.name.clone(),
882 if_exists: query.if_exists,
883 model: CollectionModel::Vault,
884 },
885 ),
886 CollectionModel::Hll => self.execute_probabilistic_command(
887 raw_query,
888 &ProbabilisticCommand::DropHll {
889 name: query.name.clone(),
890 if_exists: query.if_exists,
891 },
892 ),
893 CollectionModel::Sketch => self.execute_probabilistic_command(
894 raw_query,
895 &ProbabilisticCommand::DropSketch {
896 name: query.name.clone(),
897 if_exists: query.if_exists,
898 },
899 ),
900 CollectionModel::Filter => self.execute_probabilistic_command(
901 raw_query,
902 &ProbabilisticCommand::DropFilter {
903 name: query.name.clone(),
904 if_exists: query.if_exists,
905 },
906 ),
907 CollectionModel::Metrics => self.execute_drop_typed_collection(
908 raw_query,
909 &query.name,
910 query.if_exists,
911 CollectionModel::Metrics,
912 "metrics",
913 ),
914 CollectionModel::Mixed => self.execute_drop_typed_collection(
915 raw_query,
916 &query.name,
917 query.if_exists,
918 CollectionModel::Mixed,
919 "collection",
920 ),
921 }
922 }
923
924 fn require_graph_for_analytics(&self, name: &str) -> RedDBResult<()> {
930 let is_graph = self
931 .inner
932 .db
933 .collection_contract(name)
934 .map(|c| c.declared_model == crate::catalog::CollectionModel::Graph)
935 .unwrap_or(false);
936 if !is_graph {
937 return Err(RedDBError::Query(format!(
938 "ALTER GRAPH ... ANALYTICS: '{name}' is not a graph collection"
939 )));
940 }
941 Ok(())
942 }
943
944 pub fn execute_alter_table(
950 &self,
951 raw_query: &str,
952 query: &AlterTableQuery,
953 ) -> RedDBResult<RuntimeQueryResult> {
954 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
955 let expression_contract =
956 self.db()
957 .collection_contract(&query.name)
958 .is_some_and(|contract| {
959 crate::application::collection_contract_enforcer::has_contract_expressions(
960 &contract,
961 )
962 });
963 if query.operations.iter().any(|operation| match operation {
964 AlterOperation::AddColumn(column) => {
965 expression_contract || column.generated.is_some() || column.check.is_some()
966 }
967 AlterOperation::DropColumn(_) | AlterOperation::RenameColumn { .. } => {
968 expression_contract
969 }
970 _ => false,
971 }) {
972 return Err(RedDBError::Query("ALTER of expression schemas requires validated backfill; create a new collection and import explicitly".to_string()));
973 }
974
975 let store = self.inner.db.store();
976
977 if store.get_collection(&query.name).is_none() {
979 return Err(RedDBError::NotFound(format!(
980 "table '{}' not found",
981 query.name
982 )));
983 }
984
985 let mut messages = Vec::new();
986 let existing_contract = self.inner.db.collection_contract(&query.name);
987 for operation in &query.operations {
988 if let AlterOperation::SetDescription {
989 column: Some(name), ..
990 } = operation
991 {
992 if !existing_contract.as_ref().is_some_and(|contract| {
993 contract
994 .declared_columns
995 .iter()
996 .any(|declared| declared.name == *name)
997 }) {
998 return Err(RedDBError::NotFound(format!(
999 "column '{}' not found in collection '{}'",
1000 name, query.name
1001 )));
1002 }
1003 }
1004 }
1005
1006 let fields_added: Vec<String> = query
1008 .operations
1009 .iter()
1010 .filter_map(|op| {
1011 if let AlterOperation::AddColumn(col) = op {
1012 Some(col.name.clone())
1013 } else {
1014 None
1015 }
1016 })
1017 .collect();
1018 let fields_removed: Vec<String> = query
1019 .operations
1020 .iter()
1021 .filter_map(|op| {
1022 if let AlterOperation::DropColumn(name) = op {
1023 Some(name.clone())
1024 } else {
1025 None
1026 }
1027 })
1028 .collect();
1029
1030 for op in &query.operations {
1031 match op {
1032 AlterOperation::AddColumn(col) => {
1033 messages.push(format!("column '{}' added", col.name));
1035 }
1036 AlterOperation::DropColumn(name) => {
1037 messages.push(format!("column '{}' dropped", name));
1038 }
1039 AlterOperation::RenameColumn { from, to } => {
1040 messages.push(format!("column '{}' renamed to '{}'", from, to));
1041 }
1042 AlterOperation::SetDescription { column, .. } => {
1043 messages.push(match column {
1044 Some(name) => format!("column '{}' description set", name),
1045 None => "collection description set".to_string(),
1046 });
1047 }
1048 AlterOperation::AttachPartition { child, bound } => {
1049 store.set_config_tree(
1053 &format!("partition.{}.children.{}", query.name, child),
1054 &crate::serde_json::Value::String(bound.clone()),
1055 );
1056 messages.push(format!(
1057 "partition '{child}' attached to '{}' ({bound})",
1058 query.name
1059 ));
1060 }
1061 AlterOperation::DetachPartition { child } => {
1062 store.set_config_tree(
1063 &format!("partition.{}.children.{}", query.name, child),
1064 &crate::serde_json::Value::Null,
1065 );
1066 messages.push(format!(
1067 "partition '{child}' detached from '{}'",
1068 query.name
1069 ));
1070 }
1071 AlterOperation::EnableRowLevelSecurity => {
1072 self.inner
1073 .rls_enabled_tables
1074 .write()
1075 .insert(query.name.clone());
1076 store.set_config_tree(
1078 &format!("rls.enabled.{}", query.name),
1079 &crate::serde_json::Value::Bool(true),
1080 );
1081 self.invalidate_plan_cache();
1082 messages.push(format!("row level security enabled on '{}'", query.name));
1083 self.invalidate_result_cache();
1084 }
1085 AlterOperation::DisableRowLevelSecurity => {
1086 self.inner.rls_enabled_tables.write().remove(&query.name);
1087 store.set_config_tree(
1088 &format!("rls.enabled.{}", query.name),
1089 &crate::serde_json::Value::Null,
1090 );
1091 self.invalidate_plan_cache();
1092 messages.push(format!("row level security disabled on '{}'", query.name));
1093 self.invalidate_result_cache();
1094 }
1095 AlterOperation::EnableTenancy { column } => {
1097 store.set_config_tree(
1098 &format!("tenant_tables.{}.column", query.name),
1099 &crate::serde_json::Value::String(column.clone()),
1100 );
1101 self.register_tenant_table(&query.name, column);
1102 self.invalidate_plan_cache();
1103 messages.push(format!(
1104 "tenancy enabled on '{}' by column '{column}'",
1105 query.name
1106 ));
1107 }
1108 AlterOperation::DisableTenancy => {
1109 store.set_config_tree(
1110 &format!("tenant_tables.{}.column", query.name),
1111 &crate::serde_json::Value::Null,
1112 );
1113 self.unregister_tenant_table(&query.name);
1114 self.invalidate_plan_cache();
1115 messages.push(format!("tenancy disabled on '{}'", query.name));
1116 }
1117 AlterOperation::SetAppendOnly(on) => {
1118 messages.push(format!(
1123 "append_only {} on '{}'",
1124 if *on { "enabled" } else { "disabled" },
1125 query.name
1126 ));
1127 }
1128 AlterOperation::SetVersioned(on) => {
1129 self.vcs_set_versioned(&query.name, *on)?;
1136 messages.push(format!(
1137 "versioned {} on '{}'",
1138 if *on { "enabled" } else { "disabled" },
1139 query.name
1140 ));
1141 }
1142 AlterOperation::EnableEvents(subscription) => {
1143 let mut subscription = subscription.clone();
1144 subscription.source = query.name.clone();
1145 validate_event_subscriptions(
1146 self,
1147 &query.name,
1148 std::slice::from_ref(&subscription),
1149 )?;
1150 ensure_event_target_queue(self, &subscription.target_queue)?;
1151 messages.push(format!(
1152 "events enabled on '{}' to '{}'",
1153 query.name, subscription.target_queue
1154 ));
1155 }
1156 AlterOperation::DisableEvents => {
1157 messages.push(format!("events disabled on '{}'", query.name));
1158 }
1159 AlterOperation::AddSubscription { name, descriptor } => {
1160 let mut sub = descriptor.clone();
1161 sub.name = name.clone();
1162 sub.source = query.name.clone();
1163 validate_event_subscriptions(self, &query.name, std::slice::from_ref(&sub))?;
1164 ensure_event_target_queue(self, &sub.target_queue)?;
1165 messages.push(format!(
1166 "subscription '{}' added on '{}' to '{}'",
1167 name, query.name, sub.target_queue
1168 ));
1169 }
1170 AlterOperation::DropSubscription { name } => {
1171 messages.push(format!(
1172 "subscription '{}' dropped on '{}'",
1173 name, query.name
1174 ));
1175 }
1176 AlterOperation::AddSigner { pubkey } => {
1177 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1184 return Err(RedDBError::Query(format!(
1185 "ALTER COLLECTION ADD SIGNER: '{}' has no signer registry; \
1186 recreate it with CREATE COLLECTION ... SIGNED_BY (...)",
1187 query.name
1188 )));
1189 }
1190 let actor = crate::runtime::impl_core::current_user_projected()
1191 .unwrap_or_else(|| "@system/alter".to_string());
1192 let changed = crate::runtime::signed_writes_kind::try_add_signer(
1193 &store,
1194 &query.name,
1195 *pubkey,
1196 &actor,
1197 )?;
1198 messages.push(format!(
1199 "signer {} on '{}'",
1200 if changed { "added" } else { "already present" },
1201 query.name
1202 ));
1203 }
1204 AlterOperation::RevokeSigner { pubkey } => {
1205 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1206 return Err(RedDBError::Query(format!(
1207 "ALTER COLLECTION REVOKE SIGNER: '{}' has no signer registry",
1208 query.name
1209 )));
1210 }
1211 let actor = crate::runtime::impl_core::current_user_projected()
1212 .unwrap_or_else(|| "@system/alter".to_string());
1213 let changed = crate::runtime::signed_writes_kind::try_revoke_signer(
1214 &store,
1215 &query.name,
1216 pubkey,
1217 &actor,
1218 )?;
1219 messages.push(format!(
1220 "signer {} on '{}'",
1221 if changed {
1222 "revoked"
1223 } else {
1224 "already revoked"
1225 },
1226 query.name
1227 ));
1228 }
1229 AlterOperation::SetRetention { duration_ms } => {
1230 let existing = self.inner.db.collection_contract(&query.name);
1236 let has_ts_column = existing
1237 .as_ref()
1238 .map(retention_timestamp_column_exists)
1239 .unwrap_or(false);
1240 if !has_ts_column {
1241 return Err(RedDBError::Query(format!(
1242 "ALTER COLLECTION SET RETENTION: '{}' has no timestamp \
1243 column — declare a TIMESTAMP/TIMESTAMPMS/DATETIME column \
1244 or enable WITH timestamps = true before setting a \
1245 retention policy",
1246 query.name
1247 )));
1248 }
1249 messages.push(format!(
1250 "retention set to {duration_ms} ms on '{}'",
1251 query.name
1252 ));
1253 }
1254 AlterOperation::UnsetRetention => {
1255 messages.push(format!("retention cleared on '{}'", query.name));
1256 }
1257 AlterOperation::AddAnalytics(views) => {
1258 self.require_graph_for_analytics(&query.name)?;
1263 let existing = self.inner.db.collection_contract(&query.name);
1264 let enabled: std::collections::BTreeSet<crate::catalog::AnalyticsOutput> =
1265 existing
1266 .as_ref()
1267 .map(|c| c.analytics_config.iter().map(|v| v.output).collect())
1268 .unwrap_or_default();
1269 for view in views {
1270 if enabled.contains(&view.output) {
1271 messages.push(format!(
1274 "analytics '{}' already enabled on '{}'",
1275 view.output.as_str(),
1276 query.name
1277 ));
1278 } else {
1279 messages.push(format!(
1280 "analytics '{}' enabled on '{}'",
1281 view.output.as_str(),
1282 query.name
1283 ));
1284 }
1285 }
1286 }
1287 AlterOperation::DropAnalytics(output) => {
1288 self.require_graph_for_analytics(&query.name)?;
1289 let enabled = self
1290 .inner
1291 .db
1292 .collection_contract(&query.name)
1293 .map(|c| c.analytics_config.iter().any(|v| v.output == *output))
1294 .unwrap_or(false);
1295 if !enabled {
1296 return Err(RedDBError::Query(format!(
1299 "ALTER GRAPH DROP ANALYTICS: analytics output '{}' is not enabled on graph '{}'",
1300 output.as_str(),
1301 query.name
1302 )));
1303 }
1304 messages.push(format!(
1305 "analytics '{}' disabled on '{}'",
1306 output.as_str(),
1307 query.name
1308 ));
1309 }
1310 }
1311 }
1312
1313 let mut contract = self
1314 .inner
1315 .db
1316 .collection_contract(&query.name)
1317 .unwrap_or_else(|| default_collection_contract_for_existing_table(&query.name));
1318 apply_alter_operations_to_contract(&mut contract, &query.operations);
1319 contract.version = contract.version.saturating_add(1);
1320 contract.updated_at_unix_ms = current_unix_ms();
1321 self.inner
1322 .db
1323 .save_collection_contract(contract)
1324 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1325 if !fields_added.is_empty() || !fields_removed.is_empty() {
1329 let sub_names: Vec<String> = self
1330 .inner
1331 .db
1332 .collection_contract(&query.name)
1333 .map(|c| {
1334 c.subscriptions
1335 .iter()
1336 .filter(|s| s.enabled)
1337 .map(|s| s.name.clone())
1338 .collect()
1339 })
1340 .unwrap_or_default();
1341 if !sub_names.is_empty() {
1342 crate::telemetry::operator_event::OperatorEvent::SubscriptionSchemaChange {
1343 collection: query.name.clone(),
1344 subscription_names: sub_names.join(", "),
1345 fields_added: fields_added.join(", "),
1346 fields_removed: fields_removed.join(", "),
1347 lsn: self.cdc_current_lsn(),
1348 }
1349 .emit_global();
1350 }
1351 }
1352
1353 self.clear_table_planner_stats(&query.name);
1354 self.invalidate_result_cache();
1355 let post_alter_columns: Vec<String> = self
1360 .inner
1361 .db
1362 .collection_contract(&query.name)
1363 .map(|contract| {
1364 contract
1365 .declared_columns
1366 .iter()
1367 .map(|col| col.name.clone())
1368 .collect()
1369 })
1370 .unwrap_or_default();
1371 self.schema_vocabulary_apply(
1372 crate::runtime::schema_vocabulary::DdlEvent::AlterCollection {
1373 collection: query.name.clone(),
1374 columns: post_alter_columns,
1375 type_tags: Vec::new(),
1376 description: self
1377 .inner
1378 .db
1379 .collection_contract(&query.name)
1380 .and_then(|contract| contract.description),
1381 },
1382 );
1383 self.schema_vocabulary_sync_descriptions(&query.name);
1384
1385 let message = if messages.is_empty() {
1386 format!("table '{}' altered (no operations)", query.name)
1387 } else {
1388 format!("table '{}' altered: {}", query.name, messages.join(", "))
1389 };
1390
1391 Ok(RuntimeQueryResult::ok_message(
1392 raw_query.to_string(),
1393 &message,
1394 "alter",
1395 ))
1396 }
1397
1398 pub fn execute_create_vcs_ref(
1399 &self,
1400 raw_query: &str,
1401 query: &CreateVcsRefQuery,
1402 ) -> RedDBResult<RuntimeQueryResult> {
1403 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1404 let connection_id = crate::runtime::impl_core::current_connection_id();
1405 match query.kind {
1406 VcsRefKind::Branch => {
1407 self.vcs_branch_create(crate::application::vcs::CreateBranchInput {
1408 name: query.name.clone(),
1409 from: query.target.clone(),
1410 connection_id,
1411 })?;
1412 }
1413 VcsRefKind::Tag => {
1414 let target = match &query.target {
1415 Some(target) => target.clone(),
1416 None => self
1417 .vcs_status(crate::application::vcs::StatusInput { connection_id })?
1418 .head_commit
1419 .filter(|head| !head.is_empty())
1420 .ok_or_else(|| {
1421 RedDBError::InvalidConfig(
1422 "cannot create tag: HEAD has no commits".to_string(),
1423 )
1424 })?,
1425 };
1426 self.vcs_tag_create(crate::application::vcs::CreateTagInput {
1427 name: query.name.clone(),
1428 target,
1429 annotation: None,
1430 })?;
1431 }
1432 }
1433 let kind = match query.kind {
1434 VcsRefKind::Branch => "branch",
1435 VcsRefKind::Tag => "tag",
1436 };
1437 Ok(RuntimeQueryResult::ok_message(
1438 raw_query.to_string(),
1439 &format!("{kind} '{}' created", query.name),
1440 "create",
1441 ))
1442 }
1443
1444 pub fn execute_drop_vcs_ref(
1445 &self,
1446 raw_query: &str,
1447 query: &DropVcsRefQuery,
1448 ) -> RedDBResult<RuntimeQueryResult> {
1449 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1450 match query.kind {
1451 VcsRefKind::Branch => self.vcs_branch_delete(&query.name)?,
1452 VcsRefKind::Tag => self.vcs_tag_delete(&query.name)?,
1453 }
1454 let kind = match query.kind {
1455 VcsRefKind::Branch => "branch",
1456 VcsRefKind::Tag => "tag",
1457 };
1458 Ok(RuntimeQueryResult::ok_message(
1459 raw_query.to_string(),
1460 &format!("{kind} '{}' dropped", query.name),
1461 "drop",
1462 ))
1463 }
1464
1465 pub fn execute_fork_store(
1466 &self,
1467 raw_query: &str,
1468 query: &ForkStoreQuery,
1469 ) -> RedDBResult<RuntimeQueryResult> {
1470 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1471 let fork = self.fork_store_at_lsn(&query.name, query.at_lsn)?;
1472 let path = self
1473 .fork_store_path(&query.name)?
1474 .ok_or_else(|| RedDBError::Internal("created fork path is missing".into()))?;
1475 Ok(RuntimeQueryResult::ok_message(
1476 raw_query.to_string(),
1477 &format!(
1478 "store fork '{}' created at LSN {}; database {}",
1479 fork.name,
1480 fork.fork_lsn,
1481 path.display()
1482 ),
1483 "fork_store",
1484 ))
1485 }
1486
1487 pub fn execute_drop_fork(
1488 &self,
1489 raw_query: &str,
1490 query: &DropForkQuery,
1491 ) -> RedDBResult<RuntimeQueryResult> {
1492 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1493 if !self.drop_fork_store(&query.name)? && !query.if_exists {
1494 return Err(RedDBError::NotFound(format!("store fork '{}'", query.name)));
1495 }
1496 Ok(RuntimeQueryResult::ok_message(
1497 raw_query.to_string(),
1498 &format!("store fork '{}' dropped", query.name),
1499 "drop_fork",
1500 ))
1501 }
1502
1503 pub fn execute_promote_fork(
1504 &self,
1505 raw_query: &str,
1506 query: &PromoteForkQuery,
1507 ) -> RedDBResult<RuntimeQueryResult> {
1508 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1509 let path = self.promote_fork_store(&query.name)?;
1510 Ok(RuntimeQueryResult::ok_message(raw_query.to_string(),
1511 &format!("store fork '{}' promoted; reopen the server with database {}; the previous database is preserved", query.name, path.display()), "promote_fork"))
1512 }
1513
1514 pub fn execute_explain_alter(
1521 &self,
1522 raw_query: &str,
1523 query: &ExplainAlterQuery,
1524 ) -> RedDBResult<RuntimeQueryResult> {
1525 let analyzed = analyze_create_table(&query.target)
1529 .map_err(|err| RedDBError::Query(err.to_string()))?;
1530
1531 let current_contract = self.inner.db.collection_contract(&query.target.name);
1532
1533 let current_unique = current_contract
1536 .as_ref()
1537 .map(|contract| {
1538 contract
1539 .table_def
1540 .iter()
1541 .flat_map(|table| &table.constraints)
1542 .filter(|constraint| {
1543 constraint.constraint_type == reddb_types::ConstraintType::Unique
1544 && !contract.declared_columns.iter().any(|column| {
1545 column.unique
1546 && constraint.columns == [column.name.clone()]
1547 && constraint.name == format!("uniq_{}", column.name)
1548 })
1549 })
1550 .map(|constraint| (constraint.name.clone(), constraint.columns.clone()))
1551 .collect::<BTreeSet<_>>()
1552 })
1553 .unwrap_or_default();
1554 let target_unique = analyzed
1555 .unique_constraints
1556 .into_iter()
1557 .map(|constraint| (constraint.name, constraint.columns))
1558 .collect::<BTreeSet<_>>();
1559 if current_unique != target_unique {
1560 return Err(RedDBError::Query("NOT_YET_SUPPORTED: EXPLAIN ALTER cannot plan changes to table-level UNIQUE constraints".to_string()));
1561 }
1562
1563 let current_columns: Vec<crate::physical::DeclaredColumnContract> = current_contract
1564 .as_ref()
1565 .map(|c| c.declared_columns.clone())
1566 .unwrap_or_default();
1567
1568 let diff = super::schema_diff::compute_column_diff(
1569 &query.target.name,
1570 ¤t_columns,
1571 &query.target.columns,
1572 );
1573
1574 let rendered = match query.format {
1575 ExplainFormat::Sql => super::schema_diff::format_as_sql(&diff),
1576 ExplainFormat::Json => super::schema_diff::format_as_json(&diff),
1577 };
1578
1579 let format_label = match query.format {
1580 ExplainFormat::Sql => "sql",
1581 ExplainFormat::Json => "json",
1582 };
1583
1584 let columns = vec![
1585 "table".to_string(),
1586 "format".to_string(),
1587 "diff".to_string(),
1588 ];
1589 let row = vec![
1590 ("table".to_string(), Value::text(query.target.name.clone())),
1591 ("format".to_string(), Value::text(format_label.to_string())),
1592 ("diff".to_string(), Value::text(rendered)),
1593 ];
1594
1595 Ok(RuntimeQueryResult::ok_records(
1596 raw_query.to_string(),
1597 columns,
1598 vec![row],
1599 "explain",
1600 ))
1601 }
1602
1603 pub fn execute_create_index(
1608 &self,
1609 raw_query: &str,
1610 query: &CreateIndexQuery,
1611 ) -> RedDBResult<RuntimeQueryResult> {
1612 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1613 let topology_lock = self
1614 .inner
1615 .index_store
1616 .collection_topology_lock(&query.table);
1617 let _topology_guard = topology_lock.write();
1618 let store = self.inner.db.store();
1619
1620 let manager = store
1622 .get_collection(&query.table)
1623 .ok_or_else(|| RedDBError::NotFound(format!("table '{}' not found", query.table)))?;
1624
1625 if query.if_not_exists
1626 && self
1627 .inner
1628 .index_store
1629 .list_indices(&query.table)
1630 .iter()
1631 .any(|index| index.name == query.name)
1632 {
1633 return Ok(RuntimeQueryResult::ok_message(
1634 raw_query.to_string(),
1635 &format!(
1636 "index '{}' already exists on '{}'; skipped",
1637 query.name, query.table
1638 ),
1639 "create",
1640 ));
1641 }
1642
1643 let method_kind = match query.method {
1644 IndexMethod::Hash => super::index_store::IndexMethodKind::Hash,
1645 IndexMethod::BTree => super::index_store::IndexMethodKind::BTree,
1646 IndexMethod::Bitmap => super::index_store::IndexMethodKind::Bitmap,
1647 IndexMethod::Spatial => super::index_store::IndexMethodKind::default_spatial(),
1650 IndexMethod::H3 { resolution } => {
1651 super::index_store::IndexMethodKind::H3 { resolution }
1652 }
1653 };
1654
1655 let entities = manager.query_all(|_| true);
1665 let mut entity_fields: Vec<(crate::storage::unified::EntityId, Vec<(String, Value)>)> =
1666 entities
1667 .iter()
1668 .map(|e| {
1669 let fields = match &e.data {
1670 crate::storage::EntityData::Row(row) => {
1671 if let Some(ref named) = row.named {
1672 named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1673 } else if let Some(ref schema) = row.schema {
1674 schema
1678 .iter()
1679 .zip(row.columns.iter())
1680 .map(|(k, v)| (k.clone(), v.clone()))
1681 .collect()
1682 } else {
1683 Vec::new()
1684 }
1685 }
1686 crate::storage::EntityData::Node(node) => node
1687 .properties
1688 .iter()
1689 .map(|(k, v)| (k.clone(), v.clone()))
1690 .collect(),
1691 crate::storage::EntityData::TimeSeries(ts) => ts
1692 .fields
1693 .iter()
1694 .map(|(k, v)| (k.clone(), v.clone()))
1695 .collect(),
1696 _ => Vec::new(),
1697 };
1698 (e.id, fields)
1699 })
1700 .collect();
1701
1702 if query.unique
1703 && matches!(
1704 method_kind,
1705 super::index_store::IndexMethodKind::Hash
1706 | super::index_store::IndexMethodKind::BTree
1707 )
1708 {
1709 let snapshots = self.snapshot_manager();
1714 let own_xids = self.own_transaction_xids();
1715 let mut live = entities.iter().map(|entity| {
1716 snapshots.row_reserves_unique_key(entity.xmin, entity.xmax, &own_xids)
1717 });
1718 entity_fields.retain(|_| live.next().unwrap_or(false));
1719 if let Some((earlier, later)) =
1720 super::index_store::first_duplicate_unique_key(&query.columns, &entity_fields)
1721 {
1722 return Err(RedDBError::Query(format!(
1723 "cannot create unique index '{}' on '{}': rows {} and {} share the same ({}) key",
1724 query.name,
1725 query.table,
1726 earlier.raw(),
1727 later.raw(),
1728 query.columns.join(", ")
1729 )));
1730 }
1731 }
1732
1733 let index_growth_rows = entity_fields
1734 .iter()
1735 .map(|(_, fields)| fields.clone())
1736 .collect::<Vec<_>>();
1737 let _reservation = self.admit_non_evictable_growth(
1738 crate::storage::memory_pools::MemoryPool::IndexMemory,
1739 &format!("create index {}", query.name),
1740 crate::runtime::memory_admission::estimate_index_growth(
1741 &index_growth_rows,
1742 &query.columns,
1743 ),
1744 )?;
1745
1746 let indexed_count = self
1748 .inner
1749 .index_store
1750 .create_index(
1751 &query.name,
1752 &query.table,
1753 &query.columns,
1754 method_kind,
1755 query.unique,
1756 &entity_fields,
1757 )
1758 .map_err(RedDBError::Internal)?;
1759
1760 let analyzed = crate::storage::query::planner::stats_catalog::analyze_entity_fields(
1761 &query.table,
1762 &entity_fields,
1763 );
1764 crate::storage::query::planner::stats_catalog::persist_table_stats(&store, &analyzed);
1765 self.invalidate_plan_cache();
1766
1767 self.inner
1769 .index_store
1770 .register(super::index_store::RegisteredIndex {
1771 name: query.name.clone(),
1772 collection: query.table.clone(),
1773 columns: query.columns.clone(),
1774 method: method_kind,
1775 unique: query.unique,
1776 });
1777 self.persist_runtime_index_descriptor(super::index_store::RegisteredIndex {
1778 name: query.name.clone(),
1779 collection: query.table.clone(),
1780 columns: query.columns.clone(),
1781 method: method_kind,
1782 unique: query.unique,
1783 })?;
1784 self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::CreateIndex {
1788 collection: query.table.clone(),
1789 index: query.name.clone(),
1790 columns: query.columns.clone(),
1791 });
1792
1793 let method_str = format!("{}", query.method);
1794 let unique_str = if query.unique { "unique " } else { "" };
1795 let cols = query.columns.join(", ");
1796 let total_count = entity_fields.len();
1797 let coverage_detail =
1798 if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. })
1799 && total_count > 0
1800 && indexed_count == 0
1801 {
1802 format!(
1803 "{} of {} entities indexed — no indexable geo value in '{}'; expected {}",
1804 indexed_count,
1805 total_count,
1806 cols,
1807 crate::geo::RECOGNIZED_GEO_SHAPES
1808 )
1809 } else if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. }) {
1810 format!("{} of {} entities indexed", indexed_count, total_count)
1811 } else {
1812 format!("{} entities indexed", indexed_count)
1813 };
1814
1815 Ok(RuntimeQueryResult::ok_message(
1816 raw_query.to_string(),
1817 &format!(
1818 "{}index '{}' created on '{}' ({}) using {} ({})",
1819 unique_str, query.name, query.table, cols, method_str, coverage_detail
1820 ),
1821 "create",
1822 ))
1823 }
1824
1825 pub fn execute_drop_index(
1829 &self,
1830 raw_query: &str,
1831 query: &DropIndexQuery,
1832 ) -> RedDBResult<RuntimeQueryResult> {
1833 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1834 let topology_lock = self
1835 .inner
1836 .index_store
1837 .collection_topology_lock(&query.table);
1838 let _topology_guard = topology_lock.write();
1839 let store = self.inner.db.store();
1840
1841 if store.get_collection(&query.table).is_none() {
1843 if query.if_exists {
1844 return Ok(RuntimeQueryResult::ok_message(
1845 raw_query.to_string(),
1846 &format!("table '{}' does not exist", query.table),
1847 "drop",
1848 ));
1849 }
1850 return Err(RedDBError::NotFound(format!(
1851 "table '{}' not found",
1852 query.table
1853 )));
1854 }
1855
1856 self.inner.index_store.drop_index(&query.name, &query.table);
1858 self.persist_runtime_index_drop(&query.table, &query.name)?;
1859 self.invalidate_plan_cache();
1860 self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::DropIndex {
1862 collection: query.table.clone(),
1863 index: query.name.clone(),
1864 });
1865
1866 Ok(RuntimeQueryResult::ok_message(
1867 raw_query.to_string(),
1868 &format!("index '{}' dropped from '{}'", query.name, query.table),
1869 "drop",
1870 ))
1871 }
1872
1873 fn execute_drop_typed_collection(
1874 &self,
1875 raw_query: &str,
1876 name: &str,
1877 if_exists: bool,
1878 expected_model: CollectionModel,
1879 label: &str,
1880 ) -> RedDBResult<RuntimeQueryResult> {
1881 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1882 if is_system_schema_name(name) {
1883 return Err(RedDBError::Query("system schema is read-only".to_string()));
1884 }
1885 let store = self.inner.db.store();
1886 let _publication = store.vault_publication_lock.lock();
1887 self.vcs_reject_destructive_ddl(name)?;
1888 if store.get_collection(name).is_none() {
1889 if if_exists {
1890 return Ok(RuntimeQueryResult::ok_message(
1891 raw_query.to_string(),
1892 &format!("{label} '{name}' does not exist"),
1893 "drop",
1894 ));
1895 }
1896 return Err(RedDBError::NotFound(format!("{label} '{name}' not found")));
1897 }
1898
1899 let actual = self
1900 .inner
1901 .db
1902 .collection_contract(name)
1903 .map(|contract| contract.declared_model)
1904 .map(Ok)
1905 .unwrap_or_else(|| {
1906 polymorphic_resolver::resolve(name, &self.inner.db.catalog_model_snapshot())
1907 })?;
1908 polymorphic_resolver::ensure_model_match(expected_model, actual)?;
1909 self.drop_collection_storage(raw_query, name, label)
1910 }
1911
1912 pub fn execute_truncate(
1913 &self,
1914 raw_query: &str,
1915 query: &TruncateQuery,
1916 ) -> RedDBResult<RuntimeQueryResult> {
1917 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1918 if is_system_schema_name(&query.name) {
1919 return Err(RedDBError::Query("system schema is read-only".to_string()));
1920 }
1921
1922 let label = query
1923 .model
1924 .map(polymorphic_resolver::model_name)
1925 .unwrap_or("collection");
1926 let store = self.inner.db.store();
1927 if store.get_collection(&query.name).is_none() {
1928 if query.if_exists {
1929 return Ok(RuntimeQueryResult::ok_message(
1930 raw_query.to_string(),
1931 &format!("{label} '{}' does not exist", query.name),
1932 "truncate",
1933 ));
1934 }
1935 return Err(RedDBError::NotFound(format!(
1936 "{label} '{}' not found",
1937 query.name
1938 )));
1939 }
1940
1941 let actual =
1942 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
1943 if let Some(expected) = query.model {
1944 polymorphic_resolver::ensure_model_match(expected, actual)?;
1945 }
1946
1947 if actual == CollectionModel::Queue {
1948 return self.execute_queue_command(
1949 raw_query,
1950 &QueueCommand::Purge {
1951 queue: query.name.clone(),
1952 },
1953 );
1954 }
1955
1956 let affected = self.truncate_collection_entities(&query.name)?;
1958 crate::runtime::mutation::emit_truncate_event_for_collection(self, &query.name, affected)?;
1960 self.inner.db.invalidate_vector_index(&query.name);
1961 self.clear_table_planner_stats(&query.name);
1962 self.invalidate_result_cache();
1963
1964 Ok(RuntimeQueryResult::ok_message(
1965 raw_query.to_string(),
1966 &format!(
1967 "{affected} entities truncated from {label} '{}'",
1968 query.name
1969 ),
1970 "truncate",
1971 ))
1972 }
1973
1974 fn truncate_collection_entities(&self, name: &str) -> RedDBResult<u64> {
1975 let store = self.inner.db.store();
1976 let _publication = store.vault_publication_lock.lock();
1977 self.vcs_reject_destructive_ddl(name)?;
1978 let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
1979 let store = self.inner.db.store();
1980 let Some(manager) = store.get_collection(name) else {
1981 return Ok(0);
1982 };
1983 let entities = manager.query_all(|_| true);
1984 if entities.is_empty() {
1985 return Ok(0);
1986 }
1987
1988 for entity in &entities {
1989 let fields = entity_index_fields(&entity.data);
1990 self.inner
1991 .index_store
1992 .index_entity_delete(name, entity.id, &fields)
1993 .map_err(RedDBError::Internal)?;
1994 }
1995
1996 let ids = entities.iter().map(|entity| entity.id).collect::<Vec<_>>();
1997 let deleted_ids = store
1998 .delete_batch(name, &ids)
1999 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2000 for id in &deleted_ids {
2001 store.context_index().remove_entity(*id);
2002 }
2003 Ok(deleted_ids.len() as u64)
2004 }
2005
2006 fn drop_collection_storage(
2007 &self,
2008 raw_query: &str,
2009 name: &str,
2010 label: &str,
2011 ) -> RedDBResult<RuntimeQueryResult> {
2012 let store = self.inner.db.store();
2013 let _publication = store.vault_publication_lock.lock();
2014 self.vcs_reject_destructive_ddl(name)?;
2015
2016 let final_count = store
2019 .get_collection(name)
2020 .map(|manager| manager.query_all(|_| true).len() as u64)
2021 .unwrap_or(0);
2022 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
2023 self,
2024 name,
2025 final_count,
2026 )?;
2027
2028 let topology_lock = self.inner.index_store.collection_topology_lock(name);
2029 let _topology_guard = topology_lock.write();
2030 let orphaned_indices: Vec<String> = self
2031 .inner
2032 .index_store
2033 .list_indices(name)
2034 .into_iter()
2035 .map(|index| index.name)
2036 .collect();
2037 for index_name in &orphaned_indices {
2038 self.inner.index_store.drop_index(index_name, name);
2039 }
2040
2041 store
2042 .drop_collection(name)
2043 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2044 self.inner.db.invalidate_vector_index(name);
2045 self.inner.db.clear_collection_default_ttl_ms(name);
2046 self.inner
2047 .db
2048 .remove_collection_contract(name)
2049 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2050 self.clear_table_planner_stats(name);
2051 self.invalidate_result_cache();
2052 if let Some(store) = self.inner.auth_store.read().clone() {
2053 store.invalidate_visible_collections_cache();
2054 }
2055 self.schema_vocabulary_apply(
2059 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
2060 collection: name.to_string(),
2061 },
2062 );
2063
2064 Ok(RuntimeQueryResult::ok_message(
2065 raw_query.to_string(),
2066 &format!("{label} '{name}' dropped"),
2067 "drop",
2068 ))
2069 }
2070}
2071
2072pub(crate) fn is_system_schema_name(name: &str) -> bool {
2073 let name = reddb_engine::storage::system_collection_names::canonical_or_self(name);
2074 name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_")
2075}
2076
2077fn entity_index_fields(data: &EntityData) -> Vec<(String, Value)> {
2078 match data {
2079 EntityData::Row(row) => {
2080 if let Some(ref named) = row.named {
2081 named
2082 .iter()
2083 .map(|(key, value)| (key.clone(), value.clone()))
2084 .collect()
2085 } else if let Some(ref schema) = row.schema {
2086 schema
2087 .iter()
2088 .zip(row.columns.iter())
2089 .map(|(key, value)| (key.clone(), value.clone()))
2090 .collect()
2091 } else {
2092 Vec::new()
2093 }
2094 }
2095 EntityData::Node(node) => node
2096 .properties
2097 .iter()
2098 .map(|(key, value)| (key.clone(), value.clone()))
2099 .collect(),
2100 _ => Vec::new(),
2101 }
2102}
2103
2104fn validate_ai_policy_modalities(policy: &crate::catalog::AiPolicy) -> RedDBResult<()> {
2110 use crate::runtime::ai::provider_capabilities::{Modality, Registry};
2111 let registry = Registry::new();
2112 let check = |provider: &str, model: &str, modality: Modality| -> RedDBResult<()> {
2113 registry
2114 .validate_policy_modality(provider, model, modality)
2115 .map_err(|err| RedDBError::Query(err.to_string()))
2116 };
2117 if let Some(embed) = &policy.embed {
2118 check(&embed.provider, &embed.model, Modality::Embed)?;
2119 }
2120 if let Some(moderate) = &policy.moderate {
2121 check(&moderate.provider, &moderate.model, Modality::Moderate)?;
2122 }
2123 if let Some(vision) = &policy.vision {
2124 check(&vision.provider, &vision.model, Modality::Vision)?;
2125 }
2126 Ok(())
2127}
2128
2129fn collection_contract_from_create_table(
2130 query: &CreateTableQuery,
2131) -> RedDBResult<crate::physical::CollectionContract> {
2132 reddb_engine::catalog::ddl::collection_contract_from_create_table(query, current_unix_ms())
2133 .map_err(RedDBError::Query)
2134}
2135
2136fn default_collection_contract_for_existing_table(
2137 name: &str,
2138) -> crate::physical::CollectionContract {
2139 let now = current_unix_ms();
2140 crate::physical::CollectionContract {
2141 graph_contract: None,
2142 name: name.to_string(),
2143 description: None,
2144 description_generated: false,
2145 declared_model: crate::catalog::CollectionModel::Table,
2146 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2147 origin: crate::physical::ContractOrigin::Explicit,
2148 version: 0,
2149 created_at_unix_ms: now,
2150 updated_at_unix_ms: now,
2151 default_ttl_ms: None,
2152 vector_dimension: None,
2153 vector_metric: None,
2154 context_index_fields: Vec::new(),
2155 declared_columns: Vec::new(),
2156 table_def: Some(reddb_types::TableDef::new(name.to_string())),
2157 timestamps_enabled: false,
2158 context_index_enabled: false,
2159 metrics_raw_retention_ms: None,
2160 metrics_rollup_policies: Vec::new(),
2161 metrics_tenant_identity: None,
2162 metrics_namespace: None,
2163 append_only: false,
2164 subscriptions: Vec::new(),
2165 analytics_config: Vec::new(),
2166 session_key: None,
2167 session_gap_ms: None,
2168 retention_duration_ms: None,
2169 analytical_storage: None,
2170
2171 ai_policy: None,
2172 }
2173}
2174
2175fn keyed_collection_contract(
2176 name: &str,
2177 model: crate::catalog::CollectionModel,
2178 analytics_config: Vec<crate::catalog::AnalyticsViewDescriptor>,
2179) -> crate::physical::CollectionContract {
2180 let now = current_unix_ms();
2181 crate::physical::CollectionContract {
2182 graph_contract: None,
2183 name: name.to_string(),
2184 description: None,
2185 description_generated: false,
2186 declared_model: model,
2187 schema_mode: crate::catalog::SchemaMode::Dynamic,
2188 origin: crate::physical::ContractOrigin::Explicit,
2189 version: 1,
2190 created_at_unix_ms: now,
2191 updated_at_unix_ms: now,
2192 default_ttl_ms: None,
2193 vector_dimension: None,
2194 vector_metric: None,
2195 context_index_fields: Vec::new(),
2196 declared_columns: Vec::new(),
2197 table_def: None,
2198 timestamps_enabled: false,
2199 context_index_enabled: false,
2200 metrics_raw_retention_ms: None,
2201 metrics_rollup_policies: Vec::new(),
2202 metrics_tenant_identity: None,
2203 metrics_namespace: None,
2204 append_only: false,
2205 subscriptions: Vec::new(),
2206 analytics_config,
2207 session_key: None,
2208 session_gap_ms: None,
2209 retention_duration_ms: None,
2210 analytical_storage: None,
2211
2212 ai_policy: None,
2213 }
2214}
2215
2216fn metrics_collection_contract(query: &CreateTableQuery) -> crate::physical::CollectionContract {
2217 let now = current_unix_ms();
2218 crate::physical::CollectionContract {
2219 graph_contract: None,
2220 name: query.name.clone(),
2221 description: None,
2222 description_generated: false,
2223 declared_model: crate::catalog::CollectionModel::Metrics,
2224 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2225 origin: crate::physical::ContractOrigin::Explicit,
2226 version: 1,
2227 created_at_unix_ms: now,
2228 updated_at_unix_ms: now,
2229 default_ttl_ms: query.default_ttl_ms,
2230 vector_dimension: None,
2231 vector_metric: None,
2232 context_index_fields: Vec::new(),
2233 declared_columns: Vec::new(),
2234 table_def: None,
2235 timestamps_enabled: false,
2236 context_index_enabled: false,
2237 metrics_raw_retention_ms: query.default_ttl_ms,
2238 metrics_rollup_policies: query.metrics_rollup_policies.clone(),
2239 metrics_tenant_identity: Some(
2240 query
2241 .tenant_by
2242 .clone()
2243 .unwrap_or_else(|| "current_tenant".to_string()),
2244 ),
2245 metrics_namespace: Some("default".to_string()),
2246 append_only: true,
2247 subscriptions: Vec::new(),
2248 analytics_config: Vec::new(),
2249 session_key: None,
2250 session_gap_ms: None,
2251 retention_duration_ms: None,
2252 analytical_storage: None,
2253
2254 ai_policy: None,
2255 }
2256}
2257
2258fn vector_collection_contract(query: &CreateVectorQuery) -> crate::physical::CollectionContract {
2259 let now = current_unix_ms();
2260 crate::physical::CollectionContract {
2261 graph_contract: None,
2262 name: query.name.clone(),
2263 description: None,
2264 description_generated: false,
2265 declared_model: crate::catalog::CollectionModel::Vector,
2266 schema_mode: crate::catalog::SchemaMode::Dynamic,
2267 origin: crate::physical::ContractOrigin::Explicit,
2268 version: 1,
2269 created_at_unix_ms: now,
2270 updated_at_unix_ms: now,
2271 default_ttl_ms: None,
2272 vector_dimension: Some(query.dimension),
2273 vector_metric: Some(query.metric),
2274 context_index_fields: Vec::new(),
2275 declared_columns: Vec::new(),
2276 table_def: None,
2277 timestamps_enabled: false,
2278 context_index_enabled: false,
2279 metrics_raw_retention_ms: None,
2280 metrics_rollup_policies: Vec::new(),
2281 metrics_tenant_identity: None,
2282 metrics_namespace: None,
2283 append_only: false,
2284 subscriptions: Vec::new(),
2285 analytics_config: Vec::new(),
2286 session_key: None,
2287 session_gap_ms: None,
2288 retention_duration_ms: None,
2289 analytical_storage: None,
2290
2291 ai_policy: None,
2292 }
2293}
2294
2295fn apply_alter_operations_to_contract(
2296 contract: &mut crate::physical::CollectionContract,
2297 operations: &[AlterOperation],
2298) {
2299 if contract.table_def.is_none() {
2300 contract.table_def = Some(reddb_types::TableDef::new(contract.name.clone()));
2301 }
2302 for operation in operations {
2303 match operation {
2304 AlterOperation::SetDescription {
2305 column,
2306 description,
2307 } => {
2308 if let Some(name) = column {
2309 if let Some(declared) = contract
2310 .declared_columns
2311 .iter_mut()
2312 .find(|declared| declared.name == *name)
2313 {
2314 declared.description = Some(description.clone());
2315 declared.description_generated = false;
2316 }
2317 } else {
2318 contract.description = Some(description.clone());
2319 contract.description_generated = false;
2320 }
2321 }
2322 AlterOperation::AddColumn(column) => {
2323 if !contract
2324 .declared_columns
2325 .iter()
2326 .any(|existing| existing.name == column.name)
2327 {
2328 contract
2329 .declared_columns
2330 .push(declared_column_contract_from_ddl(column));
2331 }
2332 if let Some(table_def) = contract.table_def.as_mut() {
2333 if table_def.get_column(&column.name).is_none() {
2334 if let Ok(column_def) = column_def_from_ddl(column) {
2335 if column.primary_key {
2336 table_def.primary_key.push(column.name.clone());
2337 table_def.constraints.push(
2338 reddb_types::Constraint::new(
2339 format!("pk_{}", column.name),
2340 reddb_types::ConstraintType::PrimaryKey,
2341 )
2342 .on_columns(vec![column.name.clone()]),
2343 );
2344 }
2345 if column.unique {
2346 table_def.constraints.push(
2347 reddb_types::Constraint::new(
2348 format!("uniq_{}", column.name),
2349 reddb_types::ConstraintType::Unique,
2350 )
2351 .on_columns(vec![column.name.clone()]),
2352 );
2353 }
2354 if column.not_null {
2355 table_def.constraints.push(
2356 reddb_types::Constraint::new(
2357 format!("not_null_{}", column.name),
2358 reddb_types::ConstraintType::NotNull,
2359 )
2360 .on_columns(vec![column.name.clone()]),
2361 );
2362 }
2363 table_def.columns.push(column_def);
2364 }
2365 }
2366 }
2367 }
2368 AlterOperation::DropColumn(name) => {
2369 contract
2370 .declared_columns
2371 .retain(|column| column.name != *name);
2372 if let Some(table_def) = contract.table_def.as_mut() {
2373 if let Some(index) = table_def.column_index(name) {
2374 table_def.columns.remove(index);
2375 }
2376 table_def.primary_key.retain(|column| column != name);
2377 table_def.constraints.retain(|constraint| {
2378 !constraint.columns.iter().any(|column| column == name)
2379 });
2380 table_def
2381 .indexes
2382 .retain(|index| !index.columns.iter().any(|column| column == name));
2383 }
2384 }
2385 AlterOperation::RenameColumn { from, to } => {
2386 if contract
2387 .declared_columns
2388 .iter()
2389 .any(|column| column.name == *to)
2390 {
2391 continue;
2392 }
2393 if let Some(column) = contract
2394 .declared_columns
2395 .iter_mut()
2396 .find(|column| column.name == *from)
2397 {
2398 column.name = to.clone();
2399 }
2400 if let Some(table_def) = contract.table_def.as_mut() {
2401 if let Some(column) = table_def
2402 .columns
2403 .iter_mut()
2404 .find(|column| column.name == *from)
2405 {
2406 column.name = to.clone();
2407 }
2408 for primary_key in &mut table_def.primary_key {
2409 if *primary_key == *from {
2410 *primary_key = to.clone();
2411 }
2412 }
2413 for constraint in &mut table_def.constraints {
2414 for column in &mut constraint.columns {
2415 if *column == *from {
2416 *column = to.clone();
2417 }
2418 }
2419 if let Some(ref_columns) = constraint.ref_columns.as_mut() {
2420 for column in ref_columns {
2421 if *column == *from {
2422 *column = to.clone();
2423 }
2424 }
2425 }
2426 }
2427 for index in &mut table_def.indexes {
2428 for column in &mut index.columns {
2429 if *column == *from {
2430 *column = to.clone();
2431 }
2432 }
2433 }
2434 }
2435 }
2436 AlterOperation::AttachPartition { .. } | AlterOperation::DetachPartition { .. } => {}
2439 AlterOperation::EnableRowLevelSecurity | AlterOperation::DisableRowLevelSecurity => {}
2443 AlterOperation::EnableTenancy { .. } | AlterOperation::DisableTenancy => {}
2446 AlterOperation::SetAppendOnly(on) => {
2447 contract.append_only = *on;
2448 }
2449 AlterOperation::SetVersioned(_) => {}
2452 AlterOperation::EnableEvents(subscription) => {
2453 let mut subscription = subscription.clone();
2454 subscription.source = contract.name.clone();
2455 subscription.enabled = true;
2456 if let Some(existing) = contract
2457 .subscriptions
2458 .iter_mut()
2459 .find(|existing| existing.target_queue == subscription.target_queue)
2460 {
2461 *existing = subscription;
2462 } else {
2463 contract.subscriptions.push(subscription);
2464 }
2465 }
2466 AlterOperation::DisableEvents => {
2467 for subscription in &mut contract.subscriptions {
2468 subscription.enabled = false;
2469 }
2470 }
2471 AlterOperation::AddSubscription { name, descriptor } => {
2472 let mut sub = descriptor.clone();
2473 sub.name = name.clone();
2474 sub.source = contract.name.clone();
2475 sub.enabled = true;
2476 if let Some(existing) = contract.subscriptions.iter_mut().find(|s| s.name == *name)
2477 {
2478 *existing = sub;
2479 } else {
2480 contract.subscriptions.push(sub);
2481 }
2482 }
2483 AlterOperation::DropSubscription { name } => {
2484 contract.subscriptions.retain(|s| s.name != *name);
2485 }
2486 AlterOperation::AddSigner { .. } | AlterOperation::RevokeSigner { .. } => {}
2491 AlterOperation::SetRetention { duration_ms } => {
2492 contract.retention_duration_ms = Some(*duration_ms);
2493 }
2494 AlterOperation::UnsetRetention => {
2495 contract.retention_duration_ms = None;
2496 }
2497 AlterOperation::AddAnalytics(views) => {
2501 for view in views {
2502 if !contract
2506 .analytics_config
2507 .iter()
2508 .any(|existing| existing.output == view.output)
2509 {
2510 contract.analytics_config.push(view.clone());
2511 }
2512 }
2513 }
2514 AlterOperation::DropAnalytics(output) => {
2515 contract
2516 .analytics_config
2517 .retain(|view| view.output != *output);
2518 }
2519 }
2520 }
2521}
2522
2523pub(crate) fn retention_timestamp_column_exists(
2528 contract: &crate::physical::CollectionContract,
2529) -> bool {
2530 if contract.timestamps_enabled {
2531 return true;
2532 }
2533 if matches!(
2534 contract.declared_model,
2535 crate::catalog::CollectionModel::TimeSeries | crate::catalog::CollectionModel::Metrics
2536 ) {
2537 return true;
2541 }
2542 contract
2543 .declared_columns
2544 .iter()
2545 .any(|column| is_temporal_data_type(&column.data_type))
2546}
2547
2548fn is_temporal_data_type(data_type: &str) -> bool {
2549 let upper = data_type.to_ascii_uppercase();
2550 matches!(
2551 upper.as_str(),
2552 "TIMESTAMP" | "TIMESTAMPMS" | "TIMESTAMP_MS" | "DATETIME" | "DATE"
2553 )
2554}
2555
2556fn validate_event_subscriptions(
2557 runtime: &RedDBRuntime,
2558 source: &str,
2559 subscriptions: &[crate::catalog::SubscriptionDescriptor],
2560) -> RedDBResult<()> {
2561 for subscription in subscriptions
2562 .iter()
2563 .filter(|subscription| subscription.enabled)
2564 {
2565 if subscription.all_tenants && crate::runtime::impl_core::current_tenant().is_some() {
2566 return Err(RedDBError::Query(
2567 "cross-tenant subscription requires cluster-admin capability (events:cluster_subscribe)".to_string(),
2568 ));
2569 }
2570 validate_subscription_auth(runtime, source, subscription)?;
2571 if subscription.target_queue == source
2572 || subscription_would_create_cycle(
2573 &runtime.inner.db,
2574 source,
2575 &subscription.target_queue,
2576 )
2577 {
2578 return Err(RedDBError::Query(
2579 "subscription would create cycle".to_string(),
2580 ));
2581 }
2582 audit_subscription_redact_gap(runtime, source, subscription);
2583 }
2584 Ok(())
2585}
2586
2587fn validate_subscription_auth(
2588 runtime: &RedDBRuntime,
2589 source: &str,
2590 subscription: &crate::catalog::SubscriptionDescriptor,
2591) -> RedDBResult<()> {
2592 let auth_store = match runtime.inner.auth_store.read().clone() {
2593 Some(store) => store,
2594 None => return Ok(()),
2595 };
2596 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2597 Some(identity) => identity,
2598 None => return Ok(()),
2599 };
2600 let tenant = crate::runtime::impl_core::current_tenant();
2601 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2602
2603 if auth_store.iam_authorization_enabled() {
2604 let ctx = crate::auth::policies::EvalContext {
2605 principal_tenant: tenant.clone(),
2606 current_tenant: tenant.clone(),
2607 peer_ip: None,
2608 mfa_present: false,
2609 now_ms: crate::auth::now_ms(),
2610 principal_is_admin_role: role == crate::auth::Role::Admin,
2611 principal_is_platform_scoped: principal.tenant.is_none(),
2612 };
2613 let mut source_resource = crate::auth::policies::ResourceRef::new("table", source);
2614 if let Some(t) = tenant.as_deref() {
2615 source_resource = source_resource.with_tenant(t.to_string());
2616 }
2617 if !auth_store.check_policy_authz_with_role(
2618 &principal,
2619 "select",
2620 &source_resource,
2621 &ctx,
2622 role,
2623 ) {
2624 return Err(RedDBError::Query(format!(
2625 "permission denied: principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
2626 principal, source_resource.kind, source_resource.name
2627 )));
2628 }
2629
2630 let mut target_resource =
2631 crate::auth::policies::ResourceRef::new("queue", subscription.target_queue.clone());
2632 if let Some(t) = tenant.as_deref() {
2633 target_resource = target_resource.with_tenant(t.to_string());
2634 }
2635 if !auth_store.check_policy_authz_with_role(
2636 &principal,
2637 "write",
2638 &target_resource,
2639 &ctx,
2640 role,
2641 ) {
2642 return Err(RedDBError::Query(format!(
2643 "permission denied: principal=`{}` action=`write` resource=`{}:{}` denied by IAM policy",
2644 principal, target_resource.kind, target_resource.name
2645 )));
2646 }
2647 return Ok(());
2648 }
2649
2650 let ctx = crate::auth::privileges::AuthzContext {
2651 principal: &username,
2652 effective_role: role,
2653 tenant: tenant.as_deref(),
2654 };
2655 auth_store
2656 .check_grant(
2657 &ctx,
2658 crate::auth::privileges::Action::Select,
2659 &crate::auth::privileges::Resource::table_from_name(source),
2660 )
2661 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2662 auth_store
2663 .check_grant(
2664 &ctx,
2665 crate::auth::privileges::Action::Insert,
2666 &crate::auth::privileges::Resource::table_from_name(&subscription.target_queue),
2667 )
2668 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2669 Ok(())
2670}
2671
2672fn audit_subscription_redact_gap(
2673 runtime: &RedDBRuntime,
2674 source: &str,
2675 subscription: &crate::catalog::SubscriptionDescriptor,
2676) {
2677 let auth_store = match runtime.inner.auth_store.read().clone() {
2678 Some(store) if store.iam_authorization_enabled() => store,
2679 _ => return,
2680 };
2681 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2682 Some(identity) => identity,
2683 None => return,
2684 };
2685 let tenant = crate::runtime::impl_core::current_tenant();
2686 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2687 let missing = subscription_redact_gap_columns(&auth_store, &principal, source, subscription);
2688 if missing.is_empty() {
2689 return;
2690 }
2691
2692 let columns = missing.into_iter().collect::<Vec<_>>().join(", ");
2693 tracing::warn!(
2694 target: "reddb::operator",
2695 "subscription_redact_gap: source={} target_queue={} columns=[{}]",
2696 source,
2697 subscription.target_queue,
2698 columns
2699 );
2700 let mut event = AuditEvent::builder("subscription_redact_gap")
2701 .principal(username)
2702 .source(AuditAuthSource::System)
2703 .resource(format!(
2704 "subscription:{}->{}",
2705 source, subscription.target_queue
2706 ))
2707 .outcome(Outcome::Success)
2708 .field(AuditFieldEscaper::field("source", source))
2709 .field(AuditFieldEscaper::field(
2710 "target_queue",
2711 subscription.target_queue.clone(),
2712 ))
2713 .field(AuditFieldEscaper::field(
2714 "subscription",
2715 subscription.name.clone(),
2716 ))
2717 .field(AuditFieldEscaper::field("columns", columns))
2718 .field(AuditFieldEscaper::field("role", role.as_str()));
2719 if let Some(t) = tenant {
2720 event = event.tenant(t);
2721 }
2722 runtime.inner.audit_log.record_event(event.build());
2723}
2724
2725fn subscription_redact_gap_columns(
2726 auth_store: &crate::auth::store::AuthStore,
2727 principal: &crate::auth::UserId,
2728 source: &str,
2729 subscription: &crate::catalog::SubscriptionDescriptor,
2730) -> BTreeSet<String> {
2731 let redacted: HashSet<String> = subscription
2732 .redact_fields
2733 .iter()
2734 .map(|field| field.to_ascii_lowercase())
2735 .collect();
2736 auth_store
2737 .effective_policies(principal)
2738 .iter()
2739 .flat_map(|policy| policy.statements.iter())
2740 .filter(|statement| statement.effect == crate::auth::policies::Effect::Deny)
2741 .filter(|statement| statement.actions.iter().any(action_pattern_matches_select))
2742 .flat_map(|statement| statement.resources.iter())
2743 .filter_map(|resource| denied_column_for_source(resource, source))
2744 .filter(|column| !redact_covers_column(&redacted, source, column))
2745 .collect()
2746}
2747
2748fn action_pattern_matches_select(pattern: &crate::auth::policies::ActionPattern) -> bool {
2749 match pattern {
2750 crate::auth::policies::ActionPattern::Wildcard => true,
2751 crate::auth::policies::ActionPattern::Exact(action) => action == "select",
2752 crate::auth::policies::ActionPattern::Prefix(prefix) => {
2753 "select".len() > prefix.len() + 1
2754 && "select".starts_with(prefix)
2755 && "select".as_bytes()[prefix.len()] == b':'
2756 }
2757 }
2758}
2759
2760fn denied_column_for_source(
2761 resource: &crate::auth::policies::ResourcePattern,
2762 source: &str,
2763) -> Option<String> {
2764 let crate::auth::policies::ResourcePattern::Exact { kind, name } = resource else {
2765 return None;
2766 };
2767 if kind != "column" {
2768 return None;
2769 }
2770 let column = crate::auth::ColumnRef::parse_resource_name(name).ok()?;
2771 (column.table_resource_name() == source).then_some(column.column)
2772}
2773
2774fn redact_covers_column(redacted: &HashSet<String>, source: &str, column: &str) -> bool {
2775 let column = column.to_ascii_lowercase();
2776 let qualified = format!("{}.{}", source.to_ascii_lowercase(), column);
2777 redacted.contains("*") || redacted.contains(&column) || redacted.contains(&qualified)
2778}
2779
2780fn subscription_would_create_cycle(
2781 db: &crate::storage::unified::devx::RedDB,
2782 source: &str,
2783 target: &str,
2784) -> bool {
2785 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
2786 for contract in db.collection_contracts() {
2787 for subscription in contract
2788 .subscriptions
2789 .into_iter()
2790 .filter(|subscription| subscription.enabled)
2791 {
2792 graph
2793 .entry(subscription.source)
2794 .or_default()
2795 .push(subscription.target_queue);
2796 }
2797 }
2798 graph
2799 .entry(source.to_string())
2800 .or_default()
2801 .push(target.to_string());
2802
2803 let mut stack = vec![target.to_string()];
2804 let mut seen = HashSet::new();
2805 while let Some(node) = stack.pop() {
2806 if node == source {
2807 return true;
2808 }
2809 if !seen.insert(node.clone()) {
2810 continue;
2811 }
2812 if let Some(next) = graph.get(&node) {
2813 stack.extend(next.iter().cloned());
2814 }
2815 }
2816 false
2817}
2818
2819pub(crate) fn ensure_event_target_queue_pub(
2820 runtime: &RedDBRuntime,
2821 queue: &str,
2822) -> RedDBResult<()> {
2823 ensure_event_target_queue(runtime, queue)
2824}
2825
2826fn ensure_event_target_queue(runtime: &RedDBRuntime, queue: &str) -> RedDBResult<()> {
2827 let store = runtime.inner.db.store();
2828 if store.get_collection(queue).is_some() {
2829 return Ok(());
2830 }
2831 store
2832 .create_collection(queue)
2833 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2834 crate::runtime::queue_storage_index::prepare_queue(runtime, queue)?;
2835 runtime
2836 .inner
2837 .db
2838 .save_collection_contract(event_queue_collection_contract(queue))
2839 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2840 store.set_config_tree(
2841 &format!("queue.{queue}.mode"),
2842 &crate::serde_json::Value::String("fanout".to_string()),
2843 );
2844 Ok(())
2845}
2846
2847fn event_queue_collection_contract(queue: &str) -> crate::physical::CollectionContract {
2848 let now = current_unix_ms();
2849 crate::physical::CollectionContract {
2850 graph_contract: None,
2851 name: queue.to_string(),
2852 description: None,
2853 description_generated: false,
2854 declared_model: crate::catalog::CollectionModel::Queue,
2855 schema_mode: crate::catalog::SchemaMode::Dynamic,
2856 origin: crate::physical::ContractOrigin::Implicit,
2857 version: 1,
2858 created_at_unix_ms: now,
2859 updated_at_unix_ms: now,
2860 default_ttl_ms: None,
2861 vector_dimension: None,
2862 vector_metric: None,
2863 context_index_fields: Vec::new(),
2864 declared_columns: Vec::new(),
2865 table_def: None,
2866 timestamps_enabled: false,
2867 context_index_enabled: false,
2868 metrics_raw_retention_ms: None,
2869 metrics_rollup_policies: Vec::new(),
2870 metrics_tenant_identity: None,
2871 metrics_namespace: None,
2872 append_only: true,
2873 subscriptions: Vec::new(),
2874 analytics_config: Vec::new(),
2875 session_key: None,
2876 session_gap_ms: None,
2877 retention_duration_ms: None,
2878 analytical_storage: None,
2879
2880 ai_policy: None,
2881 }
2882}
2883
2884fn column_def_from_ddl(column: &CreateColumnDef) -> RedDBResult<reddb_types::ColumnDef> {
2885 reddb_engine::catalog::ddl::column_def_from_ddl(column).map_err(RedDBError::Query)
2886}
2887
2888fn current_unix_ms() -> u128 {
2889 std::time::SystemTime::now()
2890 .duration_since(std::time::UNIX_EPOCH)
2891 .unwrap_or_default()
2892 .as_millis()
2893}
2894
2895#[cfg(test)]
2896mod tests {
2897 use crate::auth::policies::{ActionPattern, Effect, Policy, ResourcePattern, Statement};
2898 use crate::auth::store::{AuthStore, PrincipalRef};
2899 use crate::auth::UserId;
2900 use crate::auth::{AuthConfig, Role};
2901 use crate::runtime::impl_core::{clear_current_auth_identity, set_current_auth_identity};
2902 use crate::{RedDBOptions, RedDBRuntime};
2903 use reddb_types::Value;
2904 use std::sync::Arc;
2905
2906 fn make_allow_policy(id: &str, action: &str, collection: &str) -> Policy {
2907 Policy {
2908 id: id.to_string(),
2909 version: 1,
2910 tenant: None,
2911 created_at: 0,
2912 updated_at: 0,
2913 statements: vec![Statement {
2914 sid: None,
2915 effect: Effect::Allow,
2916 actions: vec![ActionPattern::Exact(action.to_string())],
2917 resources: vec![ResourcePattern::Exact {
2918 kind: "collection".to_string(),
2919 name: collection.to_string(),
2920 }],
2921 condition: None,
2922 }],
2923 }
2924 }
2925
2926 fn wire_auth_store(rt: &RedDBRuntime) -> Arc<AuthStore> {
2927 let store = Arc::new(AuthStore::new(AuthConfig::default()));
2928 *rt.inner.auth_store.write() = Some(store.clone());
2929 store
2930 }
2931
2932 #[test]
2933 fn drop_denied_without_iam_policy() {
2934 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2935 rt.execute_query("CREATE TABLE foo (id INT)").unwrap();
2936 let store = wire_auth_store(&rt);
2937 let select_only = Policy {
2939 id: "select-only".to_string(),
2940 version: 1,
2941 tenant: None,
2942 created_at: 0,
2943 updated_at: 0,
2944 statements: vec![Statement {
2945 sid: None,
2946 effect: Effect::Allow,
2947 actions: vec![ActionPattern::Exact("select".to_string())],
2948 resources: vec![ResourcePattern::Wildcard],
2949 condition: None,
2950 }],
2951 };
2952 store.put_policy_internal(select_only).unwrap();
2953 let alice = UserId::from_parts(None, "alice");
2954 store
2955 .attach_policy(PrincipalRef::User(alice), "select-only")
2956 .unwrap();
2957 set_current_auth_identity("alice".to_string(), Role::Write);
2958 let err = rt.execute_query("DROP TABLE foo").unwrap_err();
2959 clear_current_auth_identity();
2960 assert!(
2961 format!("{err}").contains("denied by IAM policy"),
2962 "got: {err}"
2963 );
2964 }
2965
2966 #[test]
2967 fn drop_allowed_with_explicit_iam_policy() {
2968 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2969 rt.execute_query("CREATE TABLE bar (id INT)").unwrap();
2970 let store = wire_auth_store(&rt);
2971 let policy = make_allow_policy("allow-drop-bar", "drop", "bar");
2972 store.put_policy_internal(policy).unwrap();
2973 let bob = UserId::from_parts(None, "bob");
2974 store
2975 .attach_policy(PrincipalRef::User(bob), "allow-drop-bar")
2976 .unwrap();
2977 set_current_auth_identity("bob".to_string(), Role::Write);
2978 rt.execute_query("DROP TABLE bar").unwrap();
2979 clear_current_auth_identity();
2980 }
2981
2982 #[test]
2983 fn drop_allowed_with_wildcard_iam_policy() {
2984 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2985 rt.execute_query("CREATE TABLE baz (id INT)").unwrap();
2986 let store = wire_auth_store(&rt);
2987 let policy = Policy {
2988 id: "allow-drop-all".to_string(),
2989 version: 1,
2990 tenant: None,
2991 created_at: 0,
2992 updated_at: 0,
2993 statements: vec![Statement {
2994 sid: None,
2995 effect: Effect::Allow,
2996 actions: vec![ActionPattern::Exact("drop".to_string())],
2997 resources: vec![ResourcePattern::Wildcard],
2998 condition: None,
2999 }],
3000 };
3001 store.put_policy_internal(policy).unwrap();
3002 let carl = UserId::from_parts(None, "carl");
3003 store
3004 .attach_policy(PrincipalRef::User(carl), "allow-drop-all")
3005 .unwrap();
3006 set_current_auth_identity("carl".to_string(), Role::Write);
3007 rt.execute_query("DROP TABLE baz").unwrap();
3008 clear_current_auth_identity();
3009 }
3010
3011 #[test]
3012 fn truncate_denied_without_iam_policy() {
3013 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3014 rt.execute_query("CREATE TABLE qux (id INT)").unwrap();
3015 let store = wire_auth_store(&rt);
3016 store
3023 .set_enforcement_mode(crate::auth::enforcement_mode::PolicyEnforcementMode::PolicyOnly);
3024 let select_only = Policy {
3026 id: "select-only-2".to_string(),
3027 version: 1,
3028 tenant: None,
3029 created_at: 0,
3030 updated_at: 0,
3031 statements: vec![Statement {
3032 sid: None,
3033 effect: Effect::Allow,
3034 actions: vec![ActionPattern::Exact("select".to_string())],
3035 resources: vec![ResourcePattern::Wildcard],
3036 condition: None,
3037 }],
3038 };
3039 store.put_policy_internal(select_only).unwrap();
3040 let dana = UserId::from_parts(None, "dana");
3041 store
3042 .attach_policy(PrincipalRef::User(dana), "select-only-2")
3043 .unwrap();
3044 set_current_auth_identity("dana".to_string(), Role::Write);
3045 let err = rt.execute_query("TRUNCATE TABLE qux").unwrap_err();
3046 clear_current_auth_identity();
3047 assert!(
3048 format!("{err}").contains("denied by IAM policy"),
3049 "got: {err}"
3050 );
3051 }
3052
3053 #[test]
3054 fn truncate_table_clears_rows_and_preserves_schema_and_indexes() {
3055 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3056 rt.execute_query("CREATE TABLE users (id INT, name TEXT)")
3057 .unwrap();
3058 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'ana'), (2, 'bob')")
3059 .unwrap();
3060 rt.execute_query("CREATE INDEX idx_users_id ON users (id) USING HASH")
3061 .unwrap();
3062
3063 let truncated = rt.execute_query("TRUNCATE TABLE users").unwrap();
3064 assert_eq!(truncated.statement_type, "truncate");
3065 assert_eq!(truncated.affected_rows, 0);
3066
3067 let empty = rt.execute_query("SELECT id FROM users").unwrap();
3068 assert!(empty.result.records.is_empty());
3069
3070 rt.execute_query("INSERT INTO users (id, name) VALUES (3, 'cy')")
3071 .unwrap();
3072 let selected = rt
3073 .execute_query("SELECT name FROM users WHERE id = 3")
3074 .unwrap();
3075 let name = selected.result.records[0].get("name").unwrap();
3076 assert_eq!(name, &Value::text("cy"));
3077 assert!(rt.db().collection_contract("users").is_some());
3078 assert!(rt
3079 .inner
3080 .index_store
3081 .list_indices("users")
3082 .iter()
3083 .any(|index| index.name == "idx_users_id"));
3084 }
3085
3086 #[test]
3087 fn truncate_collection_is_polymorphic_and_typed_mismatch_fails() {
3088 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3089 rt.execute_query("CREATE QUEUE tasks").unwrap();
3090 rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3091
3092 let err = rt.execute_query("TRUNCATE TABLE tasks").unwrap_err();
3093 assert!(format!("{err}").contains("model mismatch: expected table, got queue"));
3094
3095 rt.execute_query("TRUNCATE COLLECTION tasks").unwrap();
3096 let len = rt.execute_query("QUEUE LEN tasks").unwrap();
3097 assert_eq!(
3098 len.result.records[0].get("len"),
3099 Some(&Value::UnsignedInteger(0))
3100 );
3101 }
3102
3103 #[test]
3104 fn truncate_system_schema_is_read_only() {
3105 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3106 let err = rt
3107 .execute_query("TRUNCATE COLLECTION red.collections")
3108 .unwrap_err();
3109 assert!(format!("{err}").contains("system schema is read-only"));
3110 }
3111
3112 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3115 let result = rt
3116 .execute_query(&format!("QUEUE PEEK {queue} 100"))
3117 .expect("peek queue");
3118 result
3119 .result
3120 .records
3121 .iter()
3122 .map(
3123 |record| match record.get("payload").expect("payload column") {
3124 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3125 other => panic!("expected JSON queue payload, got {other:?}"),
3126 },
3127 )
3128 .collect()
3129 }
3130
3131 #[test]
3134 fn truncate_event_enabled_table_emits_single_truncate_event() {
3135 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3136 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3137 .unwrap();
3138 rt.execute_query(
3139 "INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')",
3140 )
3141 .unwrap();
3142
3143 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3145
3146 rt.execute_query("TRUNCATE TABLE users").unwrap();
3147
3148 let events = queue_payloads(&rt, "users_events");
3149 assert_eq!(
3151 events.len(),
3152 1,
3153 "expected 1 truncate event, got {}",
3154 events.len()
3155 );
3156 let ev = events[0].as_object().expect("event is object");
3157 assert_eq!(
3158 ev.get("op").and_then(crate::json::Value::as_str),
3159 Some("truncate")
3160 );
3161 assert_eq!(
3162 ev.get("collection").and_then(crate::json::Value::as_str),
3163 Some("users")
3164 );
3165 assert_eq!(
3166 ev.get("entities_count")
3167 .and_then(crate::json::Value::as_u64),
3168 Some(3)
3169 );
3170 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3171 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3172 assert!(ev
3173 .get("event_id")
3174 .and_then(crate::json::Value::as_str)
3175 .is_some_and(|s| !s.is_empty()));
3176 }
3177
3178 #[test]
3180 fn truncate_no_events_collection_emits_nothing() {
3181 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3182 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3183 .unwrap();
3184 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a'), (2, 'b')")
3185 .unwrap();
3186 rt.execute_query("TRUNCATE TABLE plain").unwrap();
3188 let rows = rt.execute_query("SELECT id FROM plain").unwrap();
3190 assert!(rows.result.records.is_empty());
3191 }
3192
3193 #[test]
3197 fn drop_event_enabled_table_emits_single_collection_dropped_event() {
3198 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3199 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3200 .unwrap();
3201 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob')")
3202 .unwrap();
3203
3204 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3206
3207 rt.execute_query("DROP TABLE users").unwrap();
3208
3209 let events = queue_payloads(&rt, "users_events");
3211 assert_eq!(
3212 events.len(),
3213 1,
3214 "expected 1 collection_dropped event, got {}",
3215 events.len()
3216 );
3217 let ev = events[0].as_object().expect("event is object");
3218 assert_eq!(
3219 ev.get("op").and_then(crate::json::Value::as_str),
3220 Some("collection_dropped")
3221 );
3222 assert_eq!(
3223 ev.get("collection").and_then(crate::json::Value::as_str),
3224 Some("users")
3225 );
3226 assert_eq!(
3227 ev.get("final_entities_count")
3228 .and_then(crate::json::Value::as_u64),
3229 Some(2)
3230 );
3231 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3232 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3233 assert!(ev
3234 .get("event_id")
3235 .and_then(crate::json::Value::as_str)
3236 .is_some_and(|s| !s.is_empty()));
3237
3238 let err = rt.execute_query("SELECT id FROM users").unwrap_err();
3240 assert!(
3241 format!("{err}").contains("users"),
3242 "expected not-found error"
3243 );
3244 }
3245
3246 #[test]
3249 fn drop_no_events_collection_emits_nothing() {
3250 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3251 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3252 .unwrap();
3253 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a')")
3254 .unwrap();
3255 rt.execute_query("DROP TABLE plain").unwrap();
3256 let err = rt.execute_query("SELECT id FROM plain").unwrap_err();
3258 assert!(format!("{err}").contains("plain"));
3259 }
3260
3261 #[test]
3265 fn ops_filter_insert_only_ignores_update_and_delete() {
3266 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3267 rt.execute_query(
3268 "CREATE TABLE items (id INT, val TEXT) WITH EVENTS (INSERT) TO items_events",
3269 )
3270 .unwrap();
3271 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 'a')")
3272 .unwrap();
3273 rt.execute_query("UPDATE items SET val = 'b' WHERE id = 1")
3274 .unwrap();
3275 rt.execute_query("DELETE FROM items WHERE id = 1").unwrap();
3276
3277 let events = queue_payloads(&rt, "items_events");
3278 assert_eq!(
3280 events.len(),
3281 1,
3282 "expected 1 insert event, got {}",
3283 events.len()
3284 );
3285 assert_eq!(
3286 events[0]
3287 .as_object()
3288 .unwrap()
3289 .get("op")
3290 .and_then(crate::json::Value::as_str),
3291 Some("insert")
3292 );
3293 }
3294
3295 #[test]
3297 fn where_filter_skips_rows_that_do_not_match() {
3298 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3299 rt.execute_query(
3300 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS WHERE status = 'active' TO users_events",
3301 )
3302 .unwrap();
3303
3304 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active')")
3306 .unwrap();
3307 rt.execute_query("INSERT INTO users (id, status) VALUES (2, 'inactive')")
3309 .unwrap();
3310
3311 let events = queue_payloads(&rt, "users_events");
3312 assert_eq!(
3313 events.len(),
3314 1,
3315 "expected 1 event (only active), got {}",
3316 events.len()
3317 );
3318 let ev = events[0].as_object().unwrap();
3319 assert_eq!(
3320 ev.get("op").and_then(crate::json::Value::as_str),
3321 Some("insert")
3322 );
3323 let after = ev.get("after").unwrap().as_object().unwrap();
3324 assert_eq!(
3325 after.get("status").and_then(crate::json::Value::as_str),
3326 Some("active")
3327 );
3328 }
3329
3330 #[test]
3332 fn ops_filter_and_where_filter_combined() {
3333 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3334 rt.execute_query(
3335 "CREATE TABLE items (id INT, status TEXT) WITH EVENTS (INSERT, UPDATE) WHERE status = 'active' TO items_events",
3336 )
3337 .unwrap();
3338
3339 rt.execute_query("INSERT INTO items (id, status) VALUES (1, 'active')")
3341 .unwrap();
3342 rt.execute_query("INSERT INTO items (id, status) VALUES (2, 'inactive')")
3344 .unwrap();
3345 rt.execute_query("UPDATE items SET status = 'inactive' WHERE id = 1")
3347 .unwrap();
3348 rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3350
3351 let events = queue_payloads(&rt, "items_events");
3352 assert_eq!(
3354 events.len(),
3355 1,
3356 "expected 1 event, got {}: {events:?}",
3357 events.len()
3358 );
3359 assert_eq!(
3360 events[0]
3361 .as_object()
3362 .unwrap()
3363 .get("op")
3364 .and_then(crate::json::Value::as_str),
3365 Some("insert")
3366 );
3367 }
3368
3369 #[test]
3371 fn where_filter_on_delete_checks_before_state() {
3372 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3373 rt.execute_query(
3374 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS (DELETE) WHERE status = 'active' TO users_events",
3375 )
3376 .unwrap();
3377
3378 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active'), (2, 'inactive')")
3379 .unwrap();
3380
3381 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3383 rt.execute_query("DELETE FROM users WHERE id = 2").unwrap();
3385
3386 let events = queue_payloads(&rt, "users_events");
3387 assert_eq!(
3388 events.len(),
3389 1,
3390 "expected 1 delete event, got {}",
3391 events.len()
3392 );
3393 let ev = events[0].as_object().unwrap();
3394 assert_eq!(
3395 ev.get("op").and_then(crate::json::Value::as_str),
3396 Some("delete")
3397 );
3398 }
3399
3400 #[test]
3404 fn alter_add_column_on_event_enabled_table_succeeds() {
3405 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3406 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3407 .unwrap();
3408 rt.execute_query("ALTER TABLE users ADD COLUMN phone TEXT")
3410 .unwrap();
3411 let contract = rt.db().collection_contract("users").unwrap();
3413 assert!(
3414 contract.declared_columns.iter().any(|c| c.name == "phone"),
3415 "phone column should be in contract"
3416 );
3417 assert!(
3419 contract.subscriptions.iter().any(|s| s.enabled),
3420 "subscription should remain enabled"
3421 );
3422 }
3423
3424 #[test]
3427 fn alter_drop_column_and_rls_on_event_enabled_table_succeeds() {
3428 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3429 rt.execute_query(
3430 "CREATE TABLE items (id INT, secret TEXT, status TEXT) WITH EVENTS TO items_events",
3431 )
3432 .unwrap();
3433 rt.execute_query("ALTER TABLE items DROP COLUMN secret")
3435 .unwrap();
3436 let contract = rt.db().collection_contract("items").unwrap();
3437 assert!(
3438 !contract.declared_columns.iter().any(|c| c.name == "secret"),
3439 "secret column should be removed"
3440 );
3441 rt.execute_query("ALTER TABLE items ENABLE ROW LEVEL SECURITY")
3443 .unwrap();
3444 assert!(
3446 contract.subscriptions.iter().any(|s| s.enabled),
3447 "subscription should remain enabled"
3448 );
3449 }
3450
3451 #[test]
3457 fn create_vector_marks_collection_as_turbo_baseline() {
3458 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3459 rt.execute_query("CREATE VECTOR embeddings DIM 4").unwrap();
3460 let store = rt.db().store();
3461 assert!(
3462 crate::runtime::vector_turbo_kind::is_turbo(&store, "embeddings"),
3463 "new vector collections must be turbo-marked baseline"
3464 );
3465 assert!(
3466 rt.db().turbo_state("embeddings").is_some(),
3467 "turbo_state must materialise after CREATE VECTOR"
3468 );
3469 }
3470
3471 #[test]
3475 fn create_table_does_not_mark_turbo() {
3476 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3477 rt.execute_query("CREATE TABLE plain (id INT)").unwrap();
3478 let store = rt.db().store();
3479 assert!(
3480 !crate::runtime::vector_turbo_kind::is_turbo(&store, "plain"),
3481 "non-vector collections must not gain the turbo marker"
3482 );
3483 assert!(rt.db().turbo_state("plain").is_none());
3484 }
3485
3486 #[test]
3489 fn create_collection_kind_vector_turbo_still_marked() {
3490 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3491 rt.execute_query("CREATE COLLECTION turbo_v KIND vector.turbo DIM 4")
3492 .unwrap();
3493 let store = rt.db().store();
3494 assert!(crate::runtime::vector_turbo_kind::is_turbo(
3495 &store, "turbo_v"
3496 ));
3497 assert!(rt.db().turbo_state("turbo_v").is_some());
3498 }
3499
3500 #[test]
3503 fn create_table_persists_and_introspects_ai_policy() {
3504 use crate::catalog::{ModerateDegradedMode, ModerateRejectAction};
3505 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3506 rt.execute_query(
3507 "CREATE TABLE posts (id INT, title TEXT, body TEXT, photo TEXT) WITH ( \
3508 EMBED (fields = ('title', 'body'), provider = 'openai', model = 'text-embedding-3-small'), \
3509 MODERATE (fields = ('body'), provider = 'openai', model = 'omni-moderation-latest', sync = true, degraded = closed, on_reject = flag), \
3510 VISION (image_field = 'photo', outputs = ('caption'), provider = 'openai', model = 'gpt-4o') \
3511 )",
3512 )
3513 .expect("create table with ai policy");
3514
3515 let contracts = rt.db().collection_contracts();
3516 let contract = contracts
3517 .iter()
3518 .find(|c| c.name == "posts")
3519 .expect("contract persisted");
3520 let policy = contract.ai_policy.as_ref().expect("ai policy persisted");
3521
3522 let embed = policy.embed.as_ref().expect("embed");
3523 assert_eq!(embed.fields, vec!["title".to_string(), "body".to_string()]);
3524 assert_eq!(embed.model, "text-embedding-3-small");
3525
3526 let moderate = policy.moderate.as_ref().expect("moderate");
3527 assert!(moderate.sync_gate);
3528 assert_eq!(moderate.degraded_mode, ModerateDegradedMode::Closed);
3529 assert_eq!(moderate.reject_action, ModerateRejectAction::Flag);
3530
3531 let vision = policy.vision.as_ref().expect("vision");
3532 assert_eq!(vision.image_field, "photo");
3533 assert_eq!(vision.model, "gpt-4o");
3534 }
3535
3536 #[test]
3540 fn create_table_rejects_ai_policy_incapable_provider() {
3541 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3542 let err = rt
3543 .execute_query(
3544 "CREATE TABLE bad (id INT, body TEXT) WITH ( \
3545 EMBED (fields = ('body'), provider = 'anthropic', model = 'claude-3-5-sonnet') \
3546 )",
3547 )
3548 .unwrap_err();
3549 assert!(
3550 err.to_string()
3551 .contains("cannot serve the 'embed' modality"),
3552 "{err}"
3553 );
3554 assert!(
3555 rt.db().store().get_collection("bad").is_none(),
3556 "rejected DDL must not create the collection"
3557 );
3558 }
3559
3560 #[test]
3561 fn descriptions_cover_collection_models_and_survive_reopen() {
3562 let directory = tempfile::tempdir().expect("temporary database");
3563 let path = directory.path().join("descriptions.rdb");
3564 let options = RedDBOptions::persistent(&path);
3565 {
3566 let rt = RedDBRuntime::with_options(options.clone()).expect("runtime");
3567 assert!(!rt.config_bool("ai.collection_descriptions.auto_enrich", false));
3568 rt.execute_query("CREATE TABLE contacts (email EMAIL DESCRIPTION 'Contact address', balance MONEY) WITH (DESCRIPTION = 'Contact records')").expect("table");
3569 rt.execute_query(
3570 "CREATE COLLECTION articles KIND document DESCRIPTION 'Published articles'",
3571 )
3572 .expect("document");
3573 rt.execute_query("ALTER COLLECTION articles SET DESCRIPTION 'Editorial articles'")
3574 .expect("alter document");
3575 rt.execute_query(
3576 "ALTER COLLECTION contacts ALTER COLUMN balance SET DESCRIPTION 'Account balance'",
3577 )
3578 .expect("alter column");
3579 let contract = rt
3580 .db()
3581 .collection_contract("contacts")
3582 .expect("table contract");
3583 assert_eq!(contract.description.as_deref(), Some("Contact records"));
3584 assert_eq!(
3585 contract.declared_columns[0].description.as_deref(),
3586 Some("Contact address")
3587 );
3588 assert_eq!(
3589 contract.declared_columns[1].description.as_deref(),
3590 Some("Account balance")
3591 );
3592 assert!(!contract.declared_columns[1].description_generated);
3593 let show_create = rt
3594 .execute_query("SHOW CREATE TABLE contacts")
3595 .expect("show create");
3596 let Some(Value::Text(ddl)) = show_create.result.records[0].get("ddl") else {
3597 panic!("DDL text")
3598 };
3599 assert!(ddl.contains("DESCRIPTION 'Account balance'"), "{ddl}");
3600 assert!(
3601 ddl.contains("WITH (DESCRIPTION = 'Contact records')"),
3602 "{ddl}"
3603 );
3604 let collections = rt
3605 .execute_query("SELECT description FROM red.collections WHERE name = 'contacts'")
3606 .expect("collections view");
3607 assert_eq!(
3608 collections.result.records[0].get("description"),
3609 Some(&Value::text("Contact records"))
3610 );
3611 let columns = rt.execute_query("SELECT description FROM red.columns WHERE collection = 'contacts' AND name = 'email'").expect("columns view");
3612 assert_eq!(
3613 columns.result.records[0].get("description"),
3614 Some(&Value::text("Contact address"))
3615 );
3616 assert!(rt
3617 .execute_query(
3618 "ALTER COLLECTION contacts ALTER COLUMN missing SET DESCRIPTION 'Nope'"
3619 )
3620 .is_err());
3621 rt.checkpoint().expect("checkpoint");
3622 }
3623 let reopened = RedDBRuntime::with_options(options).expect("reopen");
3624 assert_eq!(
3625 reopened
3626 .db()
3627 .collection_contract("articles")
3628 .unwrap()
3629 .description
3630 .as_deref(),
3631 Some("Editorial articles")
3632 );
3633 let contract = reopened.db().collection_contract("contacts").unwrap();
3634 assert_eq!(
3635 contract.declared_columns[1].description.as_deref(),
3636 Some("Account balance")
3637 );
3638 }
3639
3640 #[test]
3641 fn generated_descriptions_preserve_manual_edits_and_reject_stale_versions() {
3642 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3643 rt.execute_query("CREATE TABLE readings (email EMAIL DESCRIPTION 'Manual', amount MONEY)")
3644 .expect("table");
3645 let contract = rt.db().collection_contract("readings").expect("contract");
3646 let suggestions = std::collections::HashMap::from([
3647 ("email".to_string(), "Generated email".to_string()),
3648 ("amount".to_string(), "Generated amount".to_string()),
3649 ]);
3650 assert!(rt
3651 .db()
3652 .save_generated_descriptions_if_version(
3653 "readings",
3654 contract.version,
3655 contract.created_at_unix_ms,
3656 Some("Generated collection".to_string()),
3657 &suggestions
3658 )
3659 .expect("save generated"));
3660 let generated = rt.db().collection_contract("readings").unwrap();
3661 assert_eq!(
3662 generated.declared_columns[0].description.as_deref(),
3663 Some("Manual")
3664 );
3665 assert!(!generated.declared_columns[0].description_generated);
3666 assert_eq!(
3667 generated.declared_columns[1].description.as_deref(),
3668 Some("Generated amount")
3669 );
3670 assert!(generated.declared_columns[1].description_generated);
3671 rt.execute_query(
3672 "ALTER COLLECTION readings ALTER COLUMN amount SET DESCRIPTION 'Manual amount'",
3673 )
3674 .expect("manual override");
3675 assert!(!rt
3676 .db()
3677 .save_generated_descriptions_if_version(
3678 "readings",
3679 generated.version,
3680 generated.created_at_unix_ms,
3681 Some("Stale".to_string()),
3682 &suggestions
3683 )
3684 .expect("stale rejected"));
3685 let updated = rt.db().collection_contract("readings").unwrap();
3686 assert_eq!(
3687 updated.declared_columns[1].description.as_deref(),
3688 Some("Manual amount")
3689 );
3690 assert!(!updated.declared_columns[1].description_generated);
3691 }
3692
3693 #[test]
3694 fn alter_collection_description_works_across_models() {
3695 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3696 for (create, name) in [
3697 ("CREATE TABLE records (id INT)", "records"),
3698 ("CREATE DOCUMENT documents", "documents"),
3699 ("CREATE GRAPH network", "network"),
3700 ("CREATE VECTOR embeddings DIM 3", "embeddings"),
3701 (
3702 "CREATE TIMESERIES measurements RETENTION 90 d",
3703 "measurements",
3704 ),
3705 ("CREATE QUEUE jobs MAX_SIZE 100", "jobs"),
3706 ("CREATE KV settings", "settings"),
3707 ] {
3708 rt.execute_query(create).expect(create);
3709 rt.execute_query(&format!(
3710 "ALTER COLLECTION {name} SET DESCRIPTION 'User description'"
3711 ))
3712 .expect("alter collection");
3713 let contract = rt.db().collection_contract(name).expect("contract");
3714 assert_eq!(
3715 contract.description.as_deref(),
3716 Some("User description"),
3717 "{name}"
3718 );
3719 assert!(!contract.description_generated, "{name}");
3720 }
3721 }
3722}