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
643 if is_system_schema_name(&query.name) {
644 return Err(RedDBError::Query("system schema is read-only".to_string()));
645 }
646
647 let exists = store.get_collection(&query.name).is_some();
648 if !exists {
649 if query.if_exists {
650 return Ok(RuntimeQueryResult::ok_message(
651 raw_query.to_string(),
652 &format!("table '{}' does not exist", query.name),
653 "drop",
654 ));
655 }
656 return Err(RedDBError::NotFound(format!(
657 "table '{}' not found",
658 query.name
659 )));
660 }
661 let actual =
662 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
663 polymorphic_resolver::ensure_model_match(CollectionModel::Table, actual)?;
664
665 let final_count = store
668 .get_collection(&query.name)
669 .map(|manager| manager.query_all(|_| true).len() as u64)
670 .unwrap_or(0);
671 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
672 self,
673 &query.name,
674 final_count,
675 )?;
676
677 let topology_lock = self.inner.index_store.collection_topology_lock(&query.name);
678 let _topology_guard = topology_lock.write();
679 let orphaned_indices: Vec<String> = self
680 .inner
681 .index_store
682 .list_indices(&query.name)
683 .into_iter()
684 .map(|index| index.name)
685 .collect();
686 for name in &orphaned_indices {
687 self.inner.index_store.drop_index(name, &query.name);
688 }
689
690 store
691 .drop_collection(&query.name)
692 .map_err(|err| RedDBError::Internal(err.to_string()))?;
693 self.inner.db.invalidate_vector_index(&query.name);
694 self.inner.db.clear_collection_default_ttl_ms(&query.name);
695 self.inner
696 .db
697 .remove_collection_contract(&query.name)
698 .map_err(|err| RedDBError::Internal(err.to_string()))?;
699 self.clear_table_planner_stats(&query.name);
700 self.invalidate_result_cache();
701 if let Some(store) = self.inner.auth_store.read().clone() {
705 store.invalidate_visible_collections_cache();
706 }
707 self.schema_vocabulary_apply(
716 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
717 collection: query.name.clone(),
718 },
719 );
720
721 Ok(RuntimeQueryResult::ok_message(
722 raw_query.to_string(),
723 &format!("table '{}' dropped", query.name),
724 "drop",
725 ))
726 }
727
728 pub fn execute_drop_graph(
729 &self,
730 raw_query: &str,
731 query: &DropGraphQuery,
732 ) -> RedDBResult<RuntimeQueryResult> {
733 self.execute_drop_typed_collection(
734 raw_query,
735 &query.name,
736 query.if_exists,
737 CollectionModel::Graph,
738 "graph",
739 )
740 }
741
742 pub fn execute_drop_vector(
743 &self,
744 raw_query: &str,
745 query: &DropVectorQuery,
746 ) -> RedDBResult<RuntimeQueryResult> {
747 self.execute_drop_typed_collection(
748 raw_query,
749 &query.name,
750 query.if_exists,
751 CollectionModel::Vector,
752 "vector",
753 )
754 }
755
756 pub fn execute_drop_document(
757 &self,
758 raw_query: &str,
759 query: &DropDocumentQuery,
760 ) -> RedDBResult<RuntimeQueryResult> {
761 self.execute_drop_typed_collection(
762 raw_query,
763 &query.name,
764 query.if_exists,
765 CollectionModel::Document,
766 "document",
767 )
768 }
769
770 pub fn execute_drop_kv(
771 &self,
772 raw_query: &str,
773 query: &DropKvQuery,
774 ) -> RedDBResult<RuntimeQueryResult> {
775 let label = polymorphic_resolver::model_name(query.model);
776 self.execute_drop_typed_collection(
777 raw_query,
778 &query.name,
779 query.if_exists,
780 query.model,
781 label,
782 )
783 }
784
785 pub fn execute_drop_collection(
786 &self,
787 raw_query: &str,
788 query: &DropCollectionQuery,
789 ) -> RedDBResult<RuntimeQueryResult> {
790 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
791 if is_system_schema_name(&query.name) {
792 return Err(RedDBError::Query("system schema is read-only".to_string()));
793 }
794 let store = self.inner.db.store();
795 if store.get_collection(&query.name).is_none() {
796 if query.if_exists {
797 return Ok(RuntimeQueryResult::ok_message(
798 raw_query.to_string(),
799 &format!("collection '{}' does not exist", query.name),
800 "drop",
801 ));
802 }
803 return Err(RedDBError::NotFound(format!(
804 "collection '{}' not found",
805 query.name
806 )));
807 }
808
809 let actual =
810 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
811 if let Some(expected) = query.model {
812 polymorphic_resolver::ensure_model_match(expected, actual)?;
813 }
814
815 match actual {
816 CollectionModel::Table => self.execute_drop_table(
817 raw_query,
818 &DropTableQuery {
819 name: query.name.clone(),
820 if_exists: query.if_exists,
821 },
822 ),
823 CollectionModel::TimeSeries => self.execute_drop_timeseries(
824 raw_query,
825 &DropTimeSeriesQuery {
826 name: query.name.clone(),
827 if_exists: query.if_exists,
828 },
829 ),
830 CollectionModel::Queue => self.execute_drop_queue(
831 raw_query,
832 &DropQueueQuery {
833 name: query.name.clone(),
834 if_exists: query.if_exists,
835 },
836 ),
837 CollectionModel::Graph => self.execute_drop_graph(
838 raw_query,
839 &DropGraphQuery {
840 name: query.name.clone(),
841 if_exists: query.if_exists,
842 },
843 ),
844 CollectionModel::Vector => self.execute_drop_vector(
845 raw_query,
846 &DropVectorQuery {
847 name: query.name.clone(),
848 if_exists: query.if_exists,
849 },
850 ),
851 CollectionModel::Document => self.execute_drop_document(
852 raw_query,
853 &DropDocumentQuery {
854 name: query.name.clone(),
855 if_exists: query.if_exists,
856 },
857 ),
858 CollectionModel::Kv => self.execute_drop_kv(
859 raw_query,
860 &DropKvQuery {
861 name: query.name.clone(),
862 if_exists: query.if_exists,
863 model: CollectionModel::Kv,
864 },
865 ),
866 CollectionModel::Config => self.execute_drop_kv(
867 raw_query,
868 &DropKvQuery {
869 name: query.name.clone(),
870 if_exists: query.if_exists,
871 model: CollectionModel::Config,
872 },
873 ),
874 CollectionModel::Vault => self.execute_drop_kv(
875 raw_query,
876 &DropKvQuery {
877 name: query.name.clone(),
878 if_exists: query.if_exists,
879 model: CollectionModel::Vault,
880 },
881 ),
882 CollectionModel::Hll => self.execute_probabilistic_command(
883 raw_query,
884 &ProbabilisticCommand::DropHll {
885 name: query.name.clone(),
886 if_exists: query.if_exists,
887 },
888 ),
889 CollectionModel::Sketch => self.execute_probabilistic_command(
890 raw_query,
891 &ProbabilisticCommand::DropSketch {
892 name: query.name.clone(),
893 if_exists: query.if_exists,
894 },
895 ),
896 CollectionModel::Filter => self.execute_probabilistic_command(
897 raw_query,
898 &ProbabilisticCommand::DropFilter {
899 name: query.name.clone(),
900 if_exists: query.if_exists,
901 },
902 ),
903 CollectionModel::Metrics => self.execute_drop_typed_collection(
904 raw_query,
905 &query.name,
906 query.if_exists,
907 CollectionModel::Metrics,
908 "metrics",
909 ),
910 CollectionModel::Mixed => self.execute_drop_typed_collection(
911 raw_query,
912 &query.name,
913 query.if_exists,
914 CollectionModel::Mixed,
915 "collection",
916 ),
917 }
918 }
919
920 fn require_graph_for_analytics(&self, name: &str) -> RedDBResult<()> {
926 let is_graph = self
927 .inner
928 .db
929 .collection_contract(name)
930 .map(|c| c.declared_model == crate::catalog::CollectionModel::Graph)
931 .unwrap_or(false);
932 if !is_graph {
933 return Err(RedDBError::Query(format!(
934 "ALTER GRAPH ... ANALYTICS: '{name}' is not a graph collection"
935 )));
936 }
937 Ok(())
938 }
939
940 pub fn execute_alter_table(
946 &self,
947 raw_query: &str,
948 query: &AlterTableQuery,
949 ) -> RedDBResult<RuntimeQueryResult> {
950 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
951 let expression_contract =
952 self.db()
953 .collection_contract(&query.name)
954 .is_some_and(|contract| {
955 crate::application::collection_contract_enforcer::has_contract_expressions(
956 &contract,
957 )
958 });
959 if query.operations.iter().any(|operation| match operation {
960 AlterOperation::AddColumn(column) => {
961 expression_contract || column.generated.is_some() || column.check.is_some()
962 }
963 AlterOperation::DropColumn(_) | AlterOperation::RenameColumn { .. } => {
964 expression_contract
965 }
966 _ => false,
967 }) {
968 return Err(RedDBError::Query("ALTER of expression schemas requires validated backfill; create a new collection and import explicitly".to_string()));
969 }
970
971 let store = self.inner.db.store();
972
973 if store.get_collection(&query.name).is_none() {
975 return Err(RedDBError::NotFound(format!(
976 "table '{}' not found",
977 query.name
978 )));
979 }
980
981 let mut messages = Vec::new();
982 let existing_contract = self.inner.db.collection_contract(&query.name);
983 for operation in &query.operations {
984 if let AlterOperation::SetDescription {
985 column: Some(name), ..
986 } = operation
987 {
988 if !existing_contract.as_ref().is_some_and(|contract| {
989 contract
990 .declared_columns
991 .iter()
992 .any(|declared| declared.name == *name)
993 }) {
994 return Err(RedDBError::NotFound(format!(
995 "column '{}' not found in collection '{}'",
996 name, query.name
997 )));
998 }
999 }
1000 }
1001
1002 let fields_added: Vec<String> = query
1004 .operations
1005 .iter()
1006 .filter_map(|op| {
1007 if let AlterOperation::AddColumn(col) = op {
1008 Some(col.name.clone())
1009 } else {
1010 None
1011 }
1012 })
1013 .collect();
1014 let fields_removed: Vec<String> = query
1015 .operations
1016 .iter()
1017 .filter_map(|op| {
1018 if let AlterOperation::DropColumn(name) = op {
1019 Some(name.clone())
1020 } else {
1021 None
1022 }
1023 })
1024 .collect();
1025
1026 for op in &query.operations {
1027 match op {
1028 AlterOperation::AddColumn(col) => {
1029 messages.push(format!("column '{}' added", col.name));
1031 }
1032 AlterOperation::DropColumn(name) => {
1033 messages.push(format!("column '{}' dropped", name));
1034 }
1035 AlterOperation::RenameColumn { from, to } => {
1036 messages.push(format!("column '{}' renamed to '{}'", from, to));
1037 }
1038 AlterOperation::SetDescription { column, .. } => {
1039 messages.push(match column {
1040 Some(name) => format!("column '{}' description set", name),
1041 None => "collection description set".to_string(),
1042 });
1043 }
1044 AlterOperation::AttachPartition { child, bound } => {
1045 store.set_config_tree(
1049 &format!("partition.{}.children.{}", query.name, child),
1050 &crate::serde_json::Value::String(bound.clone()),
1051 );
1052 messages.push(format!(
1053 "partition '{child}' attached to '{}' ({bound})",
1054 query.name
1055 ));
1056 }
1057 AlterOperation::DetachPartition { child } => {
1058 store.set_config_tree(
1059 &format!("partition.{}.children.{}", query.name, child),
1060 &crate::serde_json::Value::Null,
1061 );
1062 messages.push(format!(
1063 "partition '{child}' detached from '{}'",
1064 query.name
1065 ));
1066 }
1067 AlterOperation::EnableRowLevelSecurity => {
1068 self.inner
1069 .rls_enabled_tables
1070 .write()
1071 .insert(query.name.clone());
1072 store.set_config_tree(
1074 &format!("rls.enabled.{}", query.name),
1075 &crate::serde_json::Value::Bool(true),
1076 );
1077 self.invalidate_plan_cache();
1078 messages.push(format!("row level security enabled on '{}'", query.name));
1079 self.invalidate_result_cache();
1080 }
1081 AlterOperation::DisableRowLevelSecurity => {
1082 self.inner.rls_enabled_tables.write().remove(&query.name);
1083 store.set_config_tree(
1084 &format!("rls.enabled.{}", query.name),
1085 &crate::serde_json::Value::Null,
1086 );
1087 self.invalidate_plan_cache();
1088 messages.push(format!("row level security disabled on '{}'", query.name));
1089 self.invalidate_result_cache();
1090 }
1091 AlterOperation::EnableTenancy { column } => {
1093 store.set_config_tree(
1094 &format!("tenant_tables.{}.column", query.name),
1095 &crate::serde_json::Value::String(column.clone()),
1096 );
1097 self.register_tenant_table(&query.name, column);
1098 self.invalidate_plan_cache();
1099 messages.push(format!(
1100 "tenancy enabled on '{}' by column '{column}'",
1101 query.name
1102 ));
1103 }
1104 AlterOperation::DisableTenancy => {
1105 store.set_config_tree(
1106 &format!("tenant_tables.{}.column", query.name),
1107 &crate::serde_json::Value::Null,
1108 );
1109 self.unregister_tenant_table(&query.name);
1110 self.invalidate_plan_cache();
1111 messages.push(format!("tenancy disabled on '{}'", query.name));
1112 }
1113 AlterOperation::SetAppendOnly(on) => {
1114 messages.push(format!(
1119 "append_only {} on '{}'",
1120 if *on { "enabled" } else { "disabled" },
1121 query.name
1122 ));
1123 }
1124 AlterOperation::SetVersioned(on) => {
1125 self.vcs_set_versioned(&query.name, *on)?;
1132 messages.push(format!(
1133 "versioned {} on '{}'",
1134 if *on { "enabled" } else { "disabled" },
1135 query.name
1136 ));
1137 }
1138 AlterOperation::EnableEvents(subscription) => {
1139 let mut subscription = subscription.clone();
1140 subscription.source = query.name.clone();
1141 validate_event_subscriptions(
1142 self,
1143 &query.name,
1144 std::slice::from_ref(&subscription),
1145 )?;
1146 ensure_event_target_queue(self, &subscription.target_queue)?;
1147 messages.push(format!(
1148 "events enabled on '{}' to '{}'",
1149 query.name, subscription.target_queue
1150 ));
1151 }
1152 AlterOperation::DisableEvents => {
1153 messages.push(format!("events disabled on '{}'", query.name));
1154 }
1155 AlterOperation::AddSubscription { name, descriptor } => {
1156 let mut sub = descriptor.clone();
1157 sub.name = name.clone();
1158 sub.source = query.name.clone();
1159 validate_event_subscriptions(self, &query.name, std::slice::from_ref(&sub))?;
1160 ensure_event_target_queue(self, &sub.target_queue)?;
1161 messages.push(format!(
1162 "subscription '{}' added on '{}' to '{}'",
1163 name, query.name, sub.target_queue
1164 ));
1165 }
1166 AlterOperation::DropSubscription { name } => {
1167 messages.push(format!(
1168 "subscription '{}' dropped on '{}'",
1169 name, query.name
1170 ));
1171 }
1172 AlterOperation::AddSigner { pubkey } => {
1173 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1180 return Err(RedDBError::Query(format!(
1181 "ALTER COLLECTION ADD SIGNER: '{}' has no signer registry; \
1182 recreate it with CREATE COLLECTION ... SIGNED_BY (...)",
1183 query.name
1184 )));
1185 }
1186 let actor = crate::runtime::impl_core::current_user_projected()
1187 .unwrap_or_else(|| "@system/alter".to_string());
1188 let changed = crate::runtime::signed_writes_kind::try_add_signer(
1189 &store,
1190 &query.name,
1191 *pubkey,
1192 &actor,
1193 )?;
1194 messages.push(format!(
1195 "signer {} on '{}'",
1196 if changed { "added" } else { "already present" },
1197 query.name
1198 ));
1199 }
1200 AlterOperation::RevokeSigner { pubkey } => {
1201 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1202 return Err(RedDBError::Query(format!(
1203 "ALTER COLLECTION REVOKE SIGNER: '{}' has no signer registry",
1204 query.name
1205 )));
1206 }
1207 let actor = crate::runtime::impl_core::current_user_projected()
1208 .unwrap_or_else(|| "@system/alter".to_string());
1209 let changed = crate::runtime::signed_writes_kind::try_revoke_signer(
1210 &store,
1211 &query.name,
1212 pubkey,
1213 &actor,
1214 )?;
1215 messages.push(format!(
1216 "signer {} on '{}'",
1217 if changed {
1218 "revoked"
1219 } else {
1220 "already revoked"
1221 },
1222 query.name
1223 ));
1224 }
1225 AlterOperation::SetRetention { duration_ms } => {
1226 let existing = self.inner.db.collection_contract(&query.name);
1232 let has_ts_column = existing
1233 .as_ref()
1234 .map(retention_timestamp_column_exists)
1235 .unwrap_or(false);
1236 if !has_ts_column {
1237 return Err(RedDBError::Query(format!(
1238 "ALTER COLLECTION SET RETENTION: '{}' has no timestamp \
1239 column — declare a TIMESTAMP/TIMESTAMPMS/DATETIME column \
1240 or enable WITH timestamps = true before setting a \
1241 retention policy",
1242 query.name
1243 )));
1244 }
1245 messages.push(format!(
1246 "retention set to {duration_ms} ms on '{}'",
1247 query.name
1248 ));
1249 }
1250 AlterOperation::UnsetRetention => {
1251 messages.push(format!("retention cleared on '{}'", query.name));
1252 }
1253 AlterOperation::AddAnalytics(views) => {
1254 self.require_graph_for_analytics(&query.name)?;
1259 let existing = self.inner.db.collection_contract(&query.name);
1260 let enabled: std::collections::BTreeSet<crate::catalog::AnalyticsOutput> =
1261 existing
1262 .as_ref()
1263 .map(|c| c.analytics_config.iter().map(|v| v.output).collect())
1264 .unwrap_or_default();
1265 for view in views {
1266 if enabled.contains(&view.output) {
1267 messages.push(format!(
1270 "analytics '{}' already enabled on '{}'",
1271 view.output.as_str(),
1272 query.name
1273 ));
1274 } else {
1275 messages.push(format!(
1276 "analytics '{}' enabled on '{}'",
1277 view.output.as_str(),
1278 query.name
1279 ));
1280 }
1281 }
1282 }
1283 AlterOperation::DropAnalytics(output) => {
1284 self.require_graph_for_analytics(&query.name)?;
1285 let enabled = self
1286 .inner
1287 .db
1288 .collection_contract(&query.name)
1289 .map(|c| c.analytics_config.iter().any(|v| v.output == *output))
1290 .unwrap_or(false);
1291 if !enabled {
1292 return Err(RedDBError::Query(format!(
1295 "ALTER GRAPH DROP ANALYTICS: analytics output '{}' is not enabled on graph '{}'",
1296 output.as_str(),
1297 query.name
1298 )));
1299 }
1300 messages.push(format!(
1301 "analytics '{}' disabled on '{}'",
1302 output.as_str(),
1303 query.name
1304 ));
1305 }
1306 }
1307 }
1308
1309 let mut contract = self
1310 .inner
1311 .db
1312 .collection_contract(&query.name)
1313 .unwrap_or_else(|| default_collection_contract_for_existing_table(&query.name));
1314 apply_alter_operations_to_contract(&mut contract, &query.operations);
1315 contract.version = contract.version.saturating_add(1);
1316 contract.updated_at_unix_ms = current_unix_ms();
1317 self.inner
1318 .db
1319 .save_collection_contract(contract)
1320 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1321 if !fields_added.is_empty() || !fields_removed.is_empty() {
1325 let sub_names: Vec<String> = self
1326 .inner
1327 .db
1328 .collection_contract(&query.name)
1329 .map(|c| {
1330 c.subscriptions
1331 .iter()
1332 .filter(|s| s.enabled)
1333 .map(|s| s.name.clone())
1334 .collect()
1335 })
1336 .unwrap_or_default();
1337 if !sub_names.is_empty() {
1338 crate::telemetry::operator_event::OperatorEvent::SubscriptionSchemaChange {
1339 collection: query.name.clone(),
1340 subscription_names: sub_names.join(", "),
1341 fields_added: fields_added.join(", "),
1342 fields_removed: fields_removed.join(", "),
1343 lsn: self.cdc_current_lsn(),
1344 }
1345 .emit_global();
1346 }
1347 }
1348
1349 self.clear_table_planner_stats(&query.name);
1350 self.invalidate_result_cache();
1351 let post_alter_columns: Vec<String> = self
1356 .inner
1357 .db
1358 .collection_contract(&query.name)
1359 .map(|contract| {
1360 contract
1361 .declared_columns
1362 .iter()
1363 .map(|col| col.name.clone())
1364 .collect()
1365 })
1366 .unwrap_or_default();
1367 self.schema_vocabulary_apply(
1368 crate::runtime::schema_vocabulary::DdlEvent::AlterCollection {
1369 collection: query.name.clone(),
1370 columns: post_alter_columns,
1371 type_tags: Vec::new(),
1372 description: self
1373 .inner
1374 .db
1375 .collection_contract(&query.name)
1376 .and_then(|contract| contract.description),
1377 },
1378 );
1379 self.schema_vocabulary_sync_descriptions(&query.name);
1380
1381 let message = if messages.is_empty() {
1382 format!("table '{}' altered (no operations)", query.name)
1383 } else {
1384 format!("table '{}' altered: {}", query.name, messages.join(", "))
1385 };
1386
1387 Ok(RuntimeQueryResult::ok_message(
1388 raw_query.to_string(),
1389 &message,
1390 "alter",
1391 ))
1392 }
1393
1394 pub fn execute_create_vcs_ref(
1395 &self,
1396 raw_query: &str,
1397 query: &CreateVcsRefQuery,
1398 ) -> RedDBResult<RuntimeQueryResult> {
1399 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1400 let connection_id = crate::runtime::impl_core::current_connection_id();
1401 match query.kind {
1402 VcsRefKind::Branch => {
1403 self.vcs_branch_create(crate::application::vcs::CreateBranchInput {
1404 name: query.name.clone(),
1405 from: query.target.clone(),
1406 connection_id,
1407 })?;
1408 }
1409 VcsRefKind::Tag => {
1410 let target = match &query.target {
1411 Some(target) => target.clone(),
1412 None => self
1413 .vcs_status(crate::application::vcs::StatusInput { connection_id })?
1414 .head_commit
1415 .filter(|head| !head.is_empty())
1416 .ok_or_else(|| {
1417 RedDBError::InvalidConfig(
1418 "cannot create tag: HEAD has no commits".to_string(),
1419 )
1420 })?,
1421 };
1422 self.vcs_tag_create(crate::application::vcs::CreateTagInput {
1423 name: query.name.clone(),
1424 target,
1425 annotation: None,
1426 })?;
1427 }
1428 }
1429 let kind = match query.kind {
1430 VcsRefKind::Branch => "branch",
1431 VcsRefKind::Tag => "tag",
1432 };
1433 Ok(RuntimeQueryResult::ok_message(
1434 raw_query.to_string(),
1435 &format!("{kind} '{}' created", query.name),
1436 "create",
1437 ))
1438 }
1439
1440 pub fn execute_drop_vcs_ref(
1441 &self,
1442 raw_query: &str,
1443 query: &DropVcsRefQuery,
1444 ) -> RedDBResult<RuntimeQueryResult> {
1445 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1446 match query.kind {
1447 VcsRefKind::Branch => self.vcs_branch_delete(&query.name)?,
1448 VcsRefKind::Tag => self.vcs_tag_delete(&query.name)?,
1449 }
1450 let kind = match query.kind {
1451 VcsRefKind::Branch => "branch",
1452 VcsRefKind::Tag => "tag",
1453 };
1454 Ok(RuntimeQueryResult::ok_message(
1455 raw_query.to_string(),
1456 &format!("{kind} '{}' dropped", query.name),
1457 "drop",
1458 ))
1459 }
1460
1461 pub fn execute_fork_store(
1462 &self,
1463 raw_query: &str,
1464 query: &ForkStoreQuery,
1465 ) -> RedDBResult<RuntimeQueryResult> {
1466 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1467 let fork = self.fork_store_at_lsn(&query.name, query.at_lsn)?;
1468 let path = self
1469 .fork_store_path(&query.name)?
1470 .ok_or_else(|| RedDBError::Internal("created fork path is missing".into()))?;
1471 Ok(RuntimeQueryResult::ok_message(
1472 raw_query.to_string(),
1473 &format!(
1474 "store fork '{}' created at LSN {}; database {}",
1475 fork.name,
1476 fork.fork_lsn,
1477 path.display()
1478 ),
1479 "fork_store",
1480 ))
1481 }
1482
1483 pub fn execute_drop_fork(
1484 &self,
1485 raw_query: &str,
1486 query: &DropForkQuery,
1487 ) -> RedDBResult<RuntimeQueryResult> {
1488 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1489 if !self.drop_fork_store(&query.name)? && !query.if_exists {
1490 return Err(RedDBError::NotFound(format!("store fork '{}'", query.name)));
1491 }
1492 Ok(RuntimeQueryResult::ok_message(
1493 raw_query.to_string(),
1494 &format!("store fork '{}' dropped", query.name),
1495 "drop_fork",
1496 ))
1497 }
1498
1499 pub fn execute_promote_fork(
1500 &self,
1501 raw_query: &str,
1502 query: &PromoteForkQuery,
1503 ) -> RedDBResult<RuntimeQueryResult> {
1504 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1505 let path = self.promote_fork_store(&query.name)?;
1506 Ok(RuntimeQueryResult::ok_message(raw_query.to_string(),
1507 &format!("store fork '{}' promoted; reopen the server with database {}; the previous database is preserved", query.name, path.display()), "promote_fork"))
1508 }
1509
1510 pub fn execute_explain_alter(
1517 &self,
1518 raw_query: &str,
1519 query: &ExplainAlterQuery,
1520 ) -> RedDBResult<RuntimeQueryResult> {
1521 let analyzed = analyze_create_table(&query.target)
1525 .map_err(|err| RedDBError::Query(err.to_string()))?;
1526
1527 let current_contract = self.inner.db.collection_contract(&query.target.name);
1528
1529 let current_unique = current_contract
1532 .as_ref()
1533 .map(|contract| {
1534 contract
1535 .table_def
1536 .iter()
1537 .flat_map(|table| &table.constraints)
1538 .filter(|constraint| {
1539 constraint.constraint_type == reddb_types::ConstraintType::Unique
1540 && !contract.declared_columns.iter().any(|column| {
1541 column.unique
1542 && constraint.columns == [column.name.clone()]
1543 && constraint.name == format!("uniq_{}", column.name)
1544 })
1545 })
1546 .map(|constraint| (constraint.name.clone(), constraint.columns.clone()))
1547 .collect::<BTreeSet<_>>()
1548 })
1549 .unwrap_or_default();
1550 let target_unique = analyzed
1551 .unique_constraints
1552 .into_iter()
1553 .map(|constraint| (constraint.name, constraint.columns))
1554 .collect::<BTreeSet<_>>();
1555 if current_unique != target_unique {
1556 return Err(RedDBError::Query("NOT_YET_SUPPORTED: EXPLAIN ALTER cannot plan changes to table-level UNIQUE constraints".to_string()));
1557 }
1558
1559 let current_columns: Vec<crate::physical::DeclaredColumnContract> = current_contract
1560 .as_ref()
1561 .map(|c| c.declared_columns.clone())
1562 .unwrap_or_default();
1563
1564 let diff = super::schema_diff::compute_column_diff(
1565 &query.target.name,
1566 ¤t_columns,
1567 &query.target.columns,
1568 );
1569
1570 let rendered = match query.format {
1571 ExplainFormat::Sql => super::schema_diff::format_as_sql(&diff),
1572 ExplainFormat::Json => super::schema_diff::format_as_json(&diff),
1573 };
1574
1575 let format_label = match query.format {
1576 ExplainFormat::Sql => "sql",
1577 ExplainFormat::Json => "json",
1578 };
1579
1580 let columns = vec![
1581 "table".to_string(),
1582 "format".to_string(),
1583 "diff".to_string(),
1584 ];
1585 let row = vec![
1586 ("table".to_string(), Value::text(query.target.name.clone())),
1587 ("format".to_string(), Value::text(format_label.to_string())),
1588 ("diff".to_string(), Value::text(rendered)),
1589 ];
1590
1591 Ok(RuntimeQueryResult::ok_records(
1592 raw_query.to_string(),
1593 columns,
1594 vec![row],
1595 "explain",
1596 ))
1597 }
1598
1599 pub fn execute_create_index(
1604 &self,
1605 raw_query: &str,
1606 query: &CreateIndexQuery,
1607 ) -> RedDBResult<RuntimeQueryResult> {
1608 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1609 let topology_lock = self
1610 .inner
1611 .index_store
1612 .collection_topology_lock(&query.table);
1613 let _topology_guard = topology_lock.write();
1614 let store = self.inner.db.store();
1615
1616 let manager = store
1618 .get_collection(&query.table)
1619 .ok_or_else(|| RedDBError::NotFound(format!("table '{}' not found", query.table)))?;
1620
1621 if query.if_not_exists
1622 && self
1623 .inner
1624 .index_store
1625 .list_indices(&query.table)
1626 .iter()
1627 .any(|index| index.name == query.name)
1628 {
1629 return Ok(RuntimeQueryResult::ok_message(
1630 raw_query.to_string(),
1631 &format!(
1632 "index '{}' already exists on '{}'; skipped",
1633 query.name, query.table
1634 ),
1635 "create",
1636 ));
1637 }
1638
1639 let method_kind = match query.method {
1640 IndexMethod::Hash => super::index_store::IndexMethodKind::Hash,
1641 IndexMethod::BTree => super::index_store::IndexMethodKind::BTree,
1642 IndexMethod::Bitmap => super::index_store::IndexMethodKind::Bitmap,
1643 IndexMethod::Spatial => super::index_store::IndexMethodKind::default_spatial(),
1646 IndexMethod::H3 { resolution } => {
1647 super::index_store::IndexMethodKind::H3 { resolution }
1648 }
1649 };
1650
1651 let entities = manager.query_all(|_| true);
1661 let mut entity_fields: Vec<(crate::storage::unified::EntityId, Vec<(String, Value)>)> =
1662 entities
1663 .iter()
1664 .map(|e| {
1665 let fields = match &e.data {
1666 crate::storage::EntityData::Row(row) => {
1667 if let Some(ref named) = row.named {
1668 named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
1669 } else if let Some(ref schema) = row.schema {
1670 schema
1674 .iter()
1675 .zip(row.columns.iter())
1676 .map(|(k, v)| (k.clone(), v.clone()))
1677 .collect()
1678 } else {
1679 Vec::new()
1680 }
1681 }
1682 crate::storage::EntityData::Node(node) => node
1683 .properties
1684 .iter()
1685 .map(|(k, v)| (k.clone(), v.clone()))
1686 .collect(),
1687 crate::storage::EntityData::TimeSeries(ts) => ts
1688 .fields
1689 .iter()
1690 .map(|(k, v)| (k.clone(), v.clone()))
1691 .collect(),
1692 _ => Vec::new(),
1693 };
1694 (e.id, fields)
1695 })
1696 .collect();
1697
1698 if query.unique
1699 && matches!(
1700 method_kind,
1701 super::index_store::IndexMethodKind::Hash
1702 | super::index_store::IndexMethodKind::BTree
1703 )
1704 {
1705 let snapshots = self.snapshot_manager();
1710 let own_xids = self.own_transaction_xids();
1711 let mut live = entities.iter().map(|entity| {
1712 snapshots.row_reserves_unique_key(entity.xmin, entity.xmax, &own_xids)
1713 });
1714 entity_fields.retain(|_| live.next().unwrap_or(false));
1715 if let Some((earlier, later)) =
1716 super::index_store::first_duplicate_unique_key(&query.columns, &entity_fields)
1717 {
1718 return Err(RedDBError::Query(format!(
1719 "cannot create unique index '{}' on '{}': rows {} and {} share the same ({}) key",
1720 query.name,
1721 query.table,
1722 earlier.raw(),
1723 later.raw(),
1724 query.columns.join(", ")
1725 )));
1726 }
1727 }
1728
1729 let index_growth_rows = entity_fields
1730 .iter()
1731 .map(|(_, fields)| fields.clone())
1732 .collect::<Vec<_>>();
1733 let _reservation = self.admit_non_evictable_growth(
1734 crate::storage::memory_pools::MemoryPool::IndexMemory,
1735 &format!("create index {}", query.name),
1736 crate::runtime::memory_admission::estimate_index_growth(
1737 &index_growth_rows,
1738 &query.columns,
1739 ),
1740 )?;
1741
1742 let indexed_count = self
1744 .inner
1745 .index_store
1746 .create_index(
1747 &query.name,
1748 &query.table,
1749 &query.columns,
1750 method_kind,
1751 query.unique,
1752 &entity_fields,
1753 )
1754 .map_err(RedDBError::Internal)?;
1755
1756 let analyzed = crate::storage::query::planner::stats_catalog::analyze_entity_fields(
1757 &query.table,
1758 &entity_fields,
1759 );
1760 crate::storage::query::planner::stats_catalog::persist_table_stats(&store, &analyzed);
1761 self.invalidate_plan_cache();
1762
1763 self.inner
1765 .index_store
1766 .register(super::index_store::RegisteredIndex {
1767 name: query.name.clone(),
1768 collection: query.table.clone(),
1769 columns: query.columns.clone(),
1770 method: method_kind,
1771 unique: query.unique,
1772 });
1773 self.persist_runtime_index_descriptor(super::index_store::RegisteredIndex {
1774 name: query.name.clone(),
1775 collection: query.table.clone(),
1776 columns: query.columns.clone(),
1777 method: method_kind,
1778 unique: query.unique,
1779 })?;
1780 self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::CreateIndex {
1784 collection: query.table.clone(),
1785 index: query.name.clone(),
1786 columns: query.columns.clone(),
1787 });
1788
1789 let method_str = format!("{}", query.method);
1790 let unique_str = if query.unique { "unique " } else { "" };
1791 let cols = query.columns.join(", ");
1792 let total_count = entity_fields.len();
1793 let coverage_detail =
1794 if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. })
1795 && total_count > 0
1796 && indexed_count == 0
1797 {
1798 format!(
1799 "{} of {} entities indexed — no indexable geo value in '{}'; expected {}",
1800 indexed_count,
1801 total_count,
1802 cols,
1803 crate::geo::RECOGNIZED_GEO_SHAPES
1804 )
1805 } else if matches!(method_kind, super::index_store::IndexMethodKind::H3 { .. }) {
1806 format!("{} of {} entities indexed", indexed_count, total_count)
1807 } else {
1808 format!("{} entities indexed", indexed_count)
1809 };
1810
1811 Ok(RuntimeQueryResult::ok_message(
1812 raw_query.to_string(),
1813 &format!(
1814 "{}index '{}' created on '{}' ({}) using {} ({})",
1815 unique_str, query.name, query.table, cols, method_str, coverage_detail
1816 ),
1817 "create",
1818 ))
1819 }
1820
1821 pub fn execute_drop_index(
1825 &self,
1826 raw_query: &str,
1827 query: &DropIndexQuery,
1828 ) -> RedDBResult<RuntimeQueryResult> {
1829 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1830 let topology_lock = self
1831 .inner
1832 .index_store
1833 .collection_topology_lock(&query.table);
1834 let _topology_guard = topology_lock.write();
1835 let store = self.inner.db.store();
1836
1837 if store.get_collection(&query.table).is_none() {
1839 if query.if_exists {
1840 return Ok(RuntimeQueryResult::ok_message(
1841 raw_query.to_string(),
1842 &format!("table '{}' does not exist", query.table),
1843 "drop",
1844 ));
1845 }
1846 return Err(RedDBError::NotFound(format!(
1847 "table '{}' not found",
1848 query.table
1849 )));
1850 }
1851
1852 self.inner.index_store.drop_index(&query.name, &query.table);
1854 self.persist_runtime_index_drop(&query.table, &query.name)?;
1855 self.invalidate_plan_cache();
1856 self.schema_vocabulary_apply(crate::runtime::schema_vocabulary::DdlEvent::DropIndex {
1858 collection: query.table.clone(),
1859 index: query.name.clone(),
1860 });
1861
1862 Ok(RuntimeQueryResult::ok_message(
1863 raw_query.to_string(),
1864 &format!("index '{}' dropped from '{}'", query.name, query.table),
1865 "drop",
1866 ))
1867 }
1868
1869 fn execute_drop_typed_collection(
1870 &self,
1871 raw_query: &str,
1872 name: &str,
1873 if_exists: bool,
1874 expected_model: CollectionModel,
1875 label: &str,
1876 ) -> RedDBResult<RuntimeQueryResult> {
1877 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1878 if is_system_schema_name(name) {
1879 return Err(RedDBError::Query("system schema is read-only".to_string()));
1880 }
1881 let store = self.inner.db.store();
1882 if store.get_collection(name).is_none() {
1883 if if_exists {
1884 return Ok(RuntimeQueryResult::ok_message(
1885 raw_query.to_string(),
1886 &format!("{label} '{name}' does not exist"),
1887 "drop",
1888 ));
1889 }
1890 return Err(RedDBError::NotFound(format!("{label} '{name}' not found")));
1891 }
1892
1893 let actual = self
1894 .inner
1895 .db
1896 .collection_contract(name)
1897 .map(|contract| contract.declared_model)
1898 .map(Ok)
1899 .unwrap_or_else(|| {
1900 polymorphic_resolver::resolve(name, &self.inner.db.catalog_model_snapshot())
1901 })?;
1902 polymorphic_resolver::ensure_model_match(expected_model, actual)?;
1903 self.drop_collection_storage(raw_query, name, label)
1904 }
1905
1906 pub fn execute_truncate(
1907 &self,
1908 raw_query: &str,
1909 query: &TruncateQuery,
1910 ) -> RedDBResult<RuntimeQueryResult> {
1911 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1912 if is_system_schema_name(&query.name) {
1913 return Err(RedDBError::Query("system schema is read-only".to_string()));
1914 }
1915
1916 let label = query
1917 .model
1918 .map(polymorphic_resolver::model_name)
1919 .unwrap_or("collection");
1920 let store = self.inner.db.store();
1921 if store.get_collection(&query.name).is_none() {
1922 if query.if_exists {
1923 return Ok(RuntimeQueryResult::ok_message(
1924 raw_query.to_string(),
1925 &format!("{label} '{}' does not exist", query.name),
1926 "truncate",
1927 ));
1928 }
1929 return Err(RedDBError::NotFound(format!(
1930 "{label} '{}' not found",
1931 query.name
1932 )));
1933 }
1934
1935 let actual =
1936 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
1937 if let Some(expected) = query.model {
1938 polymorphic_resolver::ensure_model_match(expected, actual)?;
1939 }
1940
1941 if actual == CollectionModel::Queue {
1942 return self.execute_queue_command(
1943 raw_query,
1944 &QueueCommand::Purge {
1945 queue: query.name.clone(),
1946 },
1947 );
1948 }
1949
1950 let affected = self.truncate_collection_entities(&query.name)?;
1952 crate::runtime::mutation::emit_truncate_event_for_collection(self, &query.name, affected)?;
1954 self.inner.db.invalidate_vector_index(&query.name);
1955 self.clear_table_planner_stats(&query.name);
1956 self.invalidate_result_cache();
1957
1958 Ok(RuntimeQueryResult::ok_message(
1959 raw_query.to_string(),
1960 &format!(
1961 "{affected} entities truncated from {label} '{}'",
1962 query.name
1963 ),
1964 "truncate",
1965 ))
1966 }
1967
1968 fn truncate_collection_entities(&self, name: &str) -> RedDBResult<u64> {
1969 let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
1970 let store = self.inner.db.store();
1971 let Some(manager) = store.get_collection(name) else {
1972 return Ok(0);
1973 };
1974 let entities = manager.query_all(|_| true);
1975 if entities.is_empty() {
1976 return Ok(0);
1977 }
1978
1979 for entity in &entities {
1980 let fields = entity_index_fields(&entity.data);
1981 self.inner
1982 .index_store
1983 .index_entity_delete(name, entity.id, &fields)
1984 .map_err(RedDBError::Internal)?;
1985 }
1986
1987 let ids = entities.iter().map(|entity| entity.id).collect::<Vec<_>>();
1988 let deleted_ids = store
1989 .delete_batch(name, &ids)
1990 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1991 for id in &deleted_ids {
1992 store.context_index().remove_entity(*id);
1993 }
1994 Ok(deleted_ids.len() as u64)
1995 }
1996
1997 fn drop_collection_storage(
1998 &self,
1999 raw_query: &str,
2000 name: &str,
2001 label: &str,
2002 ) -> RedDBResult<RuntimeQueryResult> {
2003 let store = self.inner.db.store();
2004
2005 let final_count = store
2008 .get_collection(name)
2009 .map(|manager| manager.query_all(|_| true).len() as u64)
2010 .unwrap_or(0);
2011 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
2012 self,
2013 name,
2014 final_count,
2015 )?;
2016
2017 let topology_lock = self.inner.index_store.collection_topology_lock(name);
2018 let _topology_guard = topology_lock.write();
2019 let orphaned_indices: Vec<String> = self
2020 .inner
2021 .index_store
2022 .list_indices(name)
2023 .into_iter()
2024 .map(|index| index.name)
2025 .collect();
2026 for index_name in &orphaned_indices {
2027 self.inner.index_store.drop_index(index_name, name);
2028 }
2029
2030 store
2031 .drop_collection(name)
2032 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2033 self.inner.db.invalidate_vector_index(name);
2034 self.inner.db.clear_collection_default_ttl_ms(name);
2035 self.inner
2036 .db
2037 .remove_collection_contract(name)
2038 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2039 self.clear_table_planner_stats(name);
2040 self.invalidate_result_cache();
2041 if let Some(store) = self.inner.auth_store.read().clone() {
2042 store.invalidate_visible_collections_cache();
2043 }
2044 self.schema_vocabulary_apply(
2048 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
2049 collection: name.to_string(),
2050 },
2051 );
2052
2053 Ok(RuntimeQueryResult::ok_message(
2054 raw_query.to_string(),
2055 &format!("{label} '{name}' dropped"),
2056 "drop",
2057 ))
2058 }
2059}
2060
2061pub(crate) fn is_system_schema_name(name: &str) -> bool {
2062 let name = reddb_engine::storage::system_collection_names::canonical_or_self(name);
2063 name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_")
2064}
2065
2066fn entity_index_fields(data: &EntityData) -> Vec<(String, Value)> {
2067 match data {
2068 EntityData::Row(row) => {
2069 if let Some(ref named) = row.named {
2070 named
2071 .iter()
2072 .map(|(key, value)| (key.clone(), value.clone()))
2073 .collect()
2074 } else if let Some(ref schema) = row.schema {
2075 schema
2076 .iter()
2077 .zip(row.columns.iter())
2078 .map(|(key, value)| (key.clone(), value.clone()))
2079 .collect()
2080 } else {
2081 Vec::new()
2082 }
2083 }
2084 EntityData::Node(node) => node
2085 .properties
2086 .iter()
2087 .map(|(key, value)| (key.clone(), value.clone()))
2088 .collect(),
2089 _ => Vec::new(),
2090 }
2091}
2092
2093fn validate_ai_policy_modalities(policy: &crate::catalog::AiPolicy) -> RedDBResult<()> {
2099 use crate::runtime::ai::provider_capabilities::{Modality, Registry};
2100 let registry = Registry::new();
2101 let check = |provider: &str, model: &str, modality: Modality| -> RedDBResult<()> {
2102 registry
2103 .validate_policy_modality(provider, model, modality)
2104 .map_err(|err| RedDBError::Query(err.to_string()))
2105 };
2106 if let Some(embed) = &policy.embed {
2107 check(&embed.provider, &embed.model, Modality::Embed)?;
2108 }
2109 if let Some(moderate) = &policy.moderate {
2110 check(&moderate.provider, &moderate.model, Modality::Moderate)?;
2111 }
2112 if let Some(vision) = &policy.vision {
2113 check(&vision.provider, &vision.model, Modality::Vision)?;
2114 }
2115 Ok(())
2116}
2117
2118fn collection_contract_from_create_table(
2119 query: &CreateTableQuery,
2120) -> RedDBResult<crate::physical::CollectionContract> {
2121 reddb_engine::catalog::ddl::collection_contract_from_create_table(query, current_unix_ms())
2122 .map_err(RedDBError::Query)
2123}
2124
2125fn default_collection_contract_for_existing_table(
2126 name: &str,
2127) -> crate::physical::CollectionContract {
2128 let now = current_unix_ms();
2129 crate::physical::CollectionContract {
2130 name: name.to_string(),
2131 description: None,
2132 description_generated: false,
2133 declared_model: crate::catalog::CollectionModel::Table,
2134 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2135 origin: crate::physical::ContractOrigin::Explicit,
2136 version: 0,
2137 created_at_unix_ms: now,
2138 updated_at_unix_ms: now,
2139 default_ttl_ms: None,
2140 vector_dimension: None,
2141 vector_metric: None,
2142 context_index_fields: Vec::new(),
2143 declared_columns: Vec::new(),
2144 table_def: Some(reddb_types::TableDef::new(name.to_string())),
2145 timestamps_enabled: false,
2146 context_index_enabled: false,
2147 metrics_raw_retention_ms: None,
2148 metrics_rollup_policies: Vec::new(),
2149 metrics_tenant_identity: None,
2150 metrics_namespace: None,
2151 append_only: false,
2152 subscriptions: Vec::new(),
2153 analytics_config: Vec::new(),
2154 session_key: None,
2155 session_gap_ms: None,
2156 retention_duration_ms: None,
2157 analytical_storage: None,
2158
2159 ai_policy: None,
2160 }
2161}
2162
2163fn keyed_collection_contract(
2164 name: &str,
2165 model: crate::catalog::CollectionModel,
2166 analytics_config: Vec<crate::catalog::AnalyticsViewDescriptor>,
2167) -> crate::physical::CollectionContract {
2168 let now = current_unix_ms();
2169 crate::physical::CollectionContract {
2170 name: name.to_string(),
2171 description: None,
2172 description_generated: false,
2173 declared_model: model,
2174 schema_mode: crate::catalog::SchemaMode::Dynamic,
2175 origin: crate::physical::ContractOrigin::Explicit,
2176 version: 1,
2177 created_at_unix_ms: now,
2178 updated_at_unix_ms: now,
2179 default_ttl_ms: None,
2180 vector_dimension: None,
2181 vector_metric: None,
2182 context_index_fields: Vec::new(),
2183 declared_columns: Vec::new(),
2184 table_def: None,
2185 timestamps_enabled: false,
2186 context_index_enabled: false,
2187 metrics_raw_retention_ms: None,
2188 metrics_rollup_policies: Vec::new(),
2189 metrics_tenant_identity: None,
2190 metrics_namespace: None,
2191 append_only: false,
2192 subscriptions: Vec::new(),
2193 analytics_config,
2194 session_key: None,
2195 session_gap_ms: None,
2196 retention_duration_ms: None,
2197 analytical_storage: None,
2198
2199 ai_policy: None,
2200 }
2201}
2202
2203fn metrics_collection_contract(query: &CreateTableQuery) -> crate::physical::CollectionContract {
2204 let now = current_unix_ms();
2205 crate::physical::CollectionContract {
2206 name: query.name.clone(),
2207 description: None,
2208 description_generated: false,
2209 declared_model: crate::catalog::CollectionModel::Metrics,
2210 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2211 origin: crate::physical::ContractOrigin::Explicit,
2212 version: 1,
2213 created_at_unix_ms: now,
2214 updated_at_unix_ms: now,
2215 default_ttl_ms: query.default_ttl_ms,
2216 vector_dimension: None,
2217 vector_metric: None,
2218 context_index_fields: Vec::new(),
2219 declared_columns: Vec::new(),
2220 table_def: None,
2221 timestamps_enabled: false,
2222 context_index_enabled: false,
2223 metrics_raw_retention_ms: query.default_ttl_ms,
2224 metrics_rollup_policies: query.metrics_rollup_policies.clone(),
2225 metrics_tenant_identity: Some(
2226 query
2227 .tenant_by
2228 .clone()
2229 .unwrap_or_else(|| "current_tenant".to_string()),
2230 ),
2231 metrics_namespace: Some("default".to_string()),
2232 append_only: true,
2233 subscriptions: Vec::new(),
2234 analytics_config: Vec::new(),
2235 session_key: None,
2236 session_gap_ms: None,
2237 retention_duration_ms: None,
2238 analytical_storage: None,
2239
2240 ai_policy: None,
2241 }
2242}
2243
2244fn vector_collection_contract(query: &CreateVectorQuery) -> crate::physical::CollectionContract {
2245 let now = current_unix_ms();
2246 crate::physical::CollectionContract {
2247 name: query.name.clone(),
2248 description: None,
2249 description_generated: false,
2250 declared_model: crate::catalog::CollectionModel::Vector,
2251 schema_mode: crate::catalog::SchemaMode::Dynamic,
2252 origin: crate::physical::ContractOrigin::Explicit,
2253 version: 1,
2254 created_at_unix_ms: now,
2255 updated_at_unix_ms: now,
2256 default_ttl_ms: None,
2257 vector_dimension: Some(query.dimension),
2258 vector_metric: Some(query.metric),
2259 context_index_fields: Vec::new(),
2260 declared_columns: Vec::new(),
2261 table_def: None,
2262 timestamps_enabled: false,
2263 context_index_enabled: false,
2264 metrics_raw_retention_ms: None,
2265 metrics_rollup_policies: Vec::new(),
2266 metrics_tenant_identity: None,
2267 metrics_namespace: None,
2268 append_only: false,
2269 subscriptions: Vec::new(),
2270 analytics_config: Vec::new(),
2271 session_key: None,
2272 session_gap_ms: None,
2273 retention_duration_ms: None,
2274 analytical_storage: None,
2275
2276 ai_policy: None,
2277 }
2278}
2279
2280fn apply_alter_operations_to_contract(
2281 contract: &mut crate::physical::CollectionContract,
2282 operations: &[AlterOperation],
2283) {
2284 if contract.table_def.is_none() {
2285 contract.table_def = Some(reddb_types::TableDef::new(contract.name.clone()));
2286 }
2287 for operation in operations {
2288 match operation {
2289 AlterOperation::SetDescription {
2290 column,
2291 description,
2292 } => {
2293 if let Some(name) = column {
2294 if let Some(declared) = contract
2295 .declared_columns
2296 .iter_mut()
2297 .find(|declared| declared.name == *name)
2298 {
2299 declared.description = Some(description.clone());
2300 declared.description_generated = false;
2301 }
2302 } else {
2303 contract.description = Some(description.clone());
2304 contract.description_generated = false;
2305 }
2306 }
2307 AlterOperation::AddColumn(column) => {
2308 if !contract
2309 .declared_columns
2310 .iter()
2311 .any(|existing| existing.name == column.name)
2312 {
2313 contract
2314 .declared_columns
2315 .push(declared_column_contract_from_ddl(column));
2316 }
2317 if let Some(table_def) = contract.table_def.as_mut() {
2318 if table_def.get_column(&column.name).is_none() {
2319 if let Ok(column_def) = column_def_from_ddl(column) {
2320 if column.primary_key {
2321 table_def.primary_key.push(column.name.clone());
2322 table_def.constraints.push(
2323 reddb_types::Constraint::new(
2324 format!("pk_{}", column.name),
2325 reddb_types::ConstraintType::PrimaryKey,
2326 )
2327 .on_columns(vec![column.name.clone()]),
2328 );
2329 }
2330 if column.unique {
2331 table_def.constraints.push(
2332 reddb_types::Constraint::new(
2333 format!("uniq_{}", column.name),
2334 reddb_types::ConstraintType::Unique,
2335 )
2336 .on_columns(vec![column.name.clone()]),
2337 );
2338 }
2339 if column.not_null {
2340 table_def.constraints.push(
2341 reddb_types::Constraint::new(
2342 format!("not_null_{}", column.name),
2343 reddb_types::ConstraintType::NotNull,
2344 )
2345 .on_columns(vec![column.name.clone()]),
2346 );
2347 }
2348 table_def.columns.push(column_def);
2349 }
2350 }
2351 }
2352 }
2353 AlterOperation::DropColumn(name) => {
2354 contract
2355 .declared_columns
2356 .retain(|column| column.name != *name);
2357 if let Some(table_def) = contract.table_def.as_mut() {
2358 if let Some(index) = table_def.column_index(name) {
2359 table_def.columns.remove(index);
2360 }
2361 table_def.primary_key.retain(|column| column != name);
2362 table_def.constraints.retain(|constraint| {
2363 !constraint.columns.iter().any(|column| column == name)
2364 });
2365 table_def
2366 .indexes
2367 .retain(|index| !index.columns.iter().any(|column| column == name));
2368 }
2369 }
2370 AlterOperation::RenameColumn { from, to } => {
2371 if contract
2372 .declared_columns
2373 .iter()
2374 .any(|column| column.name == *to)
2375 {
2376 continue;
2377 }
2378 if let Some(column) = contract
2379 .declared_columns
2380 .iter_mut()
2381 .find(|column| column.name == *from)
2382 {
2383 column.name = to.clone();
2384 }
2385 if let Some(table_def) = contract.table_def.as_mut() {
2386 if let Some(column) = table_def
2387 .columns
2388 .iter_mut()
2389 .find(|column| column.name == *from)
2390 {
2391 column.name = to.clone();
2392 }
2393 for primary_key in &mut table_def.primary_key {
2394 if *primary_key == *from {
2395 *primary_key = to.clone();
2396 }
2397 }
2398 for constraint in &mut table_def.constraints {
2399 for column in &mut constraint.columns {
2400 if *column == *from {
2401 *column = to.clone();
2402 }
2403 }
2404 if let Some(ref_columns) = constraint.ref_columns.as_mut() {
2405 for column in ref_columns {
2406 if *column == *from {
2407 *column = to.clone();
2408 }
2409 }
2410 }
2411 }
2412 for index in &mut table_def.indexes {
2413 for column in &mut index.columns {
2414 if *column == *from {
2415 *column = to.clone();
2416 }
2417 }
2418 }
2419 }
2420 }
2421 AlterOperation::AttachPartition { .. } | AlterOperation::DetachPartition { .. } => {}
2424 AlterOperation::EnableRowLevelSecurity | AlterOperation::DisableRowLevelSecurity => {}
2428 AlterOperation::EnableTenancy { .. } | AlterOperation::DisableTenancy => {}
2431 AlterOperation::SetAppendOnly(on) => {
2432 contract.append_only = *on;
2433 }
2434 AlterOperation::SetVersioned(_) => {}
2437 AlterOperation::EnableEvents(subscription) => {
2438 let mut subscription = subscription.clone();
2439 subscription.source = contract.name.clone();
2440 subscription.enabled = true;
2441 if let Some(existing) = contract
2442 .subscriptions
2443 .iter_mut()
2444 .find(|existing| existing.target_queue == subscription.target_queue)
2445 {
2446 *existing = subscription;
2447 } else {
2448 contract.subscriptions.push(subscription);
2449 }
2450 }
2451 AlterOperation::DisableEvents => {
2452 for subscription in &mut contract.subscriptions {
2453 subscription.enabled = false;
2454 }
2455 }
2456 AlterOperation::AddSubscription { name, descriptor } => {
2457 let mut sub = descriptor.clone();
2458 sub.name = name.clone();
2459 sub.source = contract.name.clone();
2460 sub.enabled = true;
2461 if let Some(existing) = contract.subscriptions.iter_mut().find(|s| s.name == *name)
2462 {
2463 *existing = sub;
2464 } else {
2465 contract.subscriptions.push(sub);
2466 }
2467 }
2468 AlterOperation::DropSubscription { name } => {
2469 contract.subscriptions.retain(|s| s.name != *name);
2470 }
2471 AlterOperation::AddSigner { .. } | AlterOperation::RevokeSigner { .. } => {}
2476 AlterOperation::SetRetention { duration_ms } => {
2477 contract.retention_duration_ms = Some(*duration_ms);
2478 }
2479 AlterOperation::UnsetRetention => {
2480 contract.retention_duration_ms = None;
2481 }
2482 AlterOperation::AddAnalytics(views) => {
2486 for view in views {
2487 if !contract
2491 .analytics_config
2492 .iter()
2493 .any(|existing| existing.output == view.output)
2494 {
2495 contract.analytics_config.push(view.clone());
2496 }
2497 }
2498 }
2499 AlterOperation::DropAnalytics(output) => {
2500 contract
2501 .analytics_config
2502 .retain(|view| view.output != *output);
2503 }
2504 }
2505 }
2506}
2507
2508pub(crate) fn retention_timestamp_column_exists(
2513 contract: &crate::physical::CollectionContract,
2514) -> bool {
2515 if contract.timestamps_enabled {
2516 return true;
2517 }
2518 if matches!(
2519 contract.declared_model,
2520 crate::catalog::CollectionModel::TimeSeries | crate::catalog::CollectionModel::Metrics
2521 ) {
2522 return true;
2526 }
2527 contract
2528 .declared_columns
2529 .iter()
2530 .any(|column| is_temporal_data_type(&column.data_type))
2531}
2532
2533fn is_temporal_data_type(data_type: &str) -> bool {
2534 let upper = data_type.to_ascii_uppercase();
2535 matches!(
2536 upper.as_str(),
2537 "TIMESTAMP" | "TIMESTAMPMS" | "TIMESTAMP_MS" | "DATETIME" | "DATE"
2538 )
2539}
2540
2541fn validate_event_subscriptions(
2542 runtime: &RedDBRuntime,
2543 source: &str,
2544 subscriptions: &[crate::catalog::SubscriptionDescriptor],
2545) -> RedDBResult<()> {
2546 for subscription in subscriptions
2547 .iter()
2548 .filter(|subscription| subscription.enabled)
2549 {
2550 if subscription.all_tenants && crate::runtime::impl_core::current_tenant().is_some() {
2551 return Err(RedDBError::Query(
2552 "cross-tenant subscription requires cluster-admin capability (events:cluster_subscribe)".to_string(),
2553 ));
2554 }
2555 validate_subscription_auth(runtime, source, subscription)?;
2556 if subscription.target_queue == source
2557 || subscription_would_create_cycle(
2558 &runtime.inner.db,
2559 source,
2560 &subscription.target_queue,
2561 )
2562 {
2563 return Err(RedDBError::Query(
2564 "subscription would create cycle".to_string(),
2565 ));
2566 }
2567 audit_subscription_redact_gap(runtime, source, subscription);
2568 }
2569 Ok(())
2570}
2571
2572fn validate_subscription_auth(
2573 runtime: &RedDBRuntime,
2574 source: &str,
2575 subscription: &crate::catalog::SubscriptionDescriptor,
2576) -> RedDBResult<()> {
2577 let auth_store = match runtime.inner.auth_store.read().clone() {
2578 Some(store) => store,
2579 None => return Ok(()),
2580 };
2581 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2582 Some(identity) => identity,
2583 None => return Ok(()),
2584 };
2585 let tenant = crate::runtime::impl_core::current_tenant();
2586 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2587
2588 if auth_store.iam_authorization_enabled() {
2589 let ctx = crate::auth::policies::EvalContext {
2590 principal_tenant: tenant.clone(),
2591 current_tenant: tenant.clone(),
2592 peer_ip: None,
2593 mfa_present: false,
2594 now_ms: crate::auth::now_ms(),
2595 principal_is_admin_role: role == crate::auth::Role::Admin,
2596 principal_is_platform_scoped: principal.tenant.is_none(),
2597 };
2598 let mut source_resource = crate::auth::policies::ResourceRef::new("table", source);
2599 if let Some(t) = tenant.as_deref() {
2600 source_resource = source_resource.with_tenant(t.to_string());
2601 }
2602 if !auth_store.check_policy_authz_with_role(
2603 &principal,
2604 "select",
2605 &source_resource,
2606 &ctx,
2607 role,
2608 ) {
2609 return Err(RedDBError::Query(format!(
2610 "permission denied: principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
2611 principal, source_resource.kind, source_resource.name
2612 )));
2613 }
2614
2615 let mut target_resource =
2616 crate::auth::policies::ResourceRef::new("queue", subscription.target_queue.clone());
2617 if let Some(t) = tenant.as_deref() {
2618 target_resource = target_resource.with_tenant(t.to_string());
2619 }
2620 if !auth_store.check_policy_authz_with_role(
2621 &principal,
2622 "write",
2623 &target_resource,
2624 &ctx,
2625 role,
2626 ) {
2627 return Err(RedDBError::Query(format!(
2628 "permission denied: principal=`{}` action=`write` resource=`{}:{}` denied by IAM policy",
2629 principal, target_resource.kind, target_resource.name
2630 )));
2631 }
2632 return Ok(());
2633 }
2634
2635 let ctx = crate::auth::privileges::AuthzContext {
2636 principal: &username,
2637 effective_role: role,
2638 tenant: tenant.as_deref(),
2639 };
2640 auth_store
2641 .check_grant(
2642 &ctx,
2643 crate::auth::privileges::Action::Select,
2644 &crate::auth::privileges::Resource::table_from_name(source),
2645 )
2646 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2647 auth_store
2648 .check_grant(
2649 &ctx,
2650 crate::auth::privileges::Action::Insert,
2651 &crate::auth::privileges::Resource::table_from_name(&subscription.target_queue),
2652 )
2653 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2654 Ok(())
2655}
2656
2657fn audit_subscription_redact_gap(
2658 runtime: &RedDBRuntime,
2659 source: &str,
2660 subscription: &crate::catalog::SubscriptionDescriptor,
2661) {
2662 let auth_store = match runtime.inner.auth_store.read().clone() {
2663 Some(store) if store.iam_authorization_enabled() => store,
2664 _ => return,
2665 };
2666 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2667 Some(identity) => identity,
2668 None => return,
2669 };
2670 let tenant = crate::runtime::impl_core::current_tenant();
2671 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2672 let missing = subscription_redact_gap_columns(&auth_store, &principal, source, subscription);
2673 if missing.is_empty() {
2674 return;
2675 }
2676
2677 let columns = missing.into_iter().collect::<Vec<_>>().join(", ");
2678 tracing::warn!(
2679 target: "reddb::operator",
2680 "subscription_redact_gap: source={} target_queue={} columns=[{}]",
2681 source,
2682 subscription.target_queue,
2683 columns
2684 );
2685 let mut event = AuditEvent::builder("subscription_redact_gap")
2686 .principal(username)
2687 .source(AuditAuthSource::System)
2688 .resource(format!(
2689 "subscription:{}->{}",
2690 source, subscription.target_queue
2691 ))
2692 .outcome(Outcome::Success)
2693 .field(AuditFieldEscaper::field("source", source))
2694 .field(AuditFieldEscaper::field(
2695 "target_queue",
2696 subscription.target_queue.clone(),
2697 ))
2698 .field(AuditFieldEscaper::field(
2699 "subscription",
2700 subscription.name.clone(),
2701 ))
2702 .field(AuditFieldEscaper::field("columns", columns))
2703 .field(AuditFieldEscaper::field("role", role.as_str()));
2704 if let Some(t) = tenant {
2705 event = event.tenant(t);
2706 }
2707 runtime.inner.audit_log.record_event(event.build());
2708}
2709
2710fn subscription_redact_gap_columns(
2711 auth_store: &crate::auth::store::AuthStore,
2712 principal: &crate::auth::UserId,
2713 source: &str,
2714 subscription: &crate::catalog::SubscriptionDescriptor,
2715) -> BTreeSet<String> {
2716 let redacted: HashSet<String> = subscription
2717 .redact_fields
2718 .iter()
2719 .map(|field| field.to_ascii_lowercase())
2720 .collect();
2721 auth_store
2722 .effective_policies(principal)
2723 .iter()
2724 .flat_map(|policy| policy.statements.iter())
2725 .filter(|statement| statement.effect == crate::auth::policies::Effect::Deny)
2726 .filter(|statement| statement.actions.iter().any(action_pattern_matches_select))
2727 .flat_map(|statement| statement.resources.iter())
2728 .filter_map(|resource| denied_column_for_source(resource, source))
2729 .filter(|column| !redact_covers_column(&redacted, source, column))
2730 .collect()
2731}
2732
2733fn action_pattern_matches_select(pattern: &crate::auth::policies::ActionPattern) -> bool {
2734 match pattern {
2735 crate::auth::policies::ActionPattern::Wildcard => true,
2736 crate::auth::policies::ActionPattern::Exact(action) => action == "select",
2737 crate::auth::policies::ActionPattern::Prefix(prefix) => {
2738 "select".len() > prefix.len() + 1
2739 && "select".starts_with(prefix)
2740 && "select".as_bytes()[prefix.len()] == b':'
2741 }
2742 }
2743}
2744
2745fn denied_column_for_source(
2746 resource: &crate::auth::policies::ResourcePattern,
2747 source: &str,
2748) -> Option<String> {
2749 let crate::auth::policies::ResourcePattern::Exact { kind, name } = resource else {
2750 return None;
2751 };
2752 if kind != "column" {
2753 return None;
2754 }
2755 let column = crate::auth::ColumnRef::parse_resource_name(name).ok()?;
2756 (column.table_resource_name() == source).then_some(column.column)
2757}
2758
2759fn redact_covers_column(redacted: &HashSet<String>, source: &str, column: &str) -> bool {
2760 let column = column.to_ascii_lowercase();
2761 let qualified = format!("{}.{}", source.to_ascii_lowercase(), column);
2762 redacted.contains("*") || redacted.contains(&column) || redacted.contains(&qualified)
2763}
2764
2765fn subscription_would_create_cycle(
2766 db: &crate::storage::unified::devx::RedDB,
2767 source: &str,
2768 target: &str,
2769) -> bool {
2770 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
2771 for contract in db.collection_contracts() {
2772 for subscription in contract
2773 .subscriptions
2774 .into_iter()
2775 .filter(|subscription| subscription.enabled)
2776 {
2777 graph
2778 .entry(subscription.source)
2779 .or_default()
2780 .push(subscription.target_queue);
2781 }
2782 }
2783 graph
2784 .entry(source.to_string())
2785 .or_default()
2786 .push(target.to_string());
2787
2788 let mut stack = vec![target.to_string()];
2789 let mut seen = HashSet::new();
2790 while let Some(node) = stack.pop() {
2791 if node == source {
2792 return true;
2793 }
2794 if !seen.insert(node.clone()) {
2795 continue;
2796 }
2797 if let Some(next) = graph.get(&node) {
2798 stack.extend(next.iter().cloned());
2799 }
2800 }
2801 false
2802}
2803
2804pub(crate) fn ensure_event_target_queue_pub(
2805 runtime: &RedDBRuntime,
2806 queue: &str,
2807) -> RedDBResult<()> {
2808 ensure_event_target_queue(runtime, queue)
2809}
2810
2811fn ensure_event_target_queue(runtime: &RedDBRuntime, queue: &str) -> RedDBResult<()> {
2812 let store = runtime.inner.db.store();
2813 if store.get_collection(queue).is_some() {
2814 return Ok(());
2815 }
2816 store
2817 .create_collection(queue)
2818 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2819 crate::runtime::queue_storage_index::prepare_queue(runtime, queue)?;
2820 runtime
2821 .inner
2822 .db
2823 .save_collection_contract(event_queue_collection_contract(queue))
2824 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2825 store.set_config_tree(
2826 &format!("queue.{queue}.mode"),
2827 &crate::serde_json::Value::String("fanout".to_string()),
2828 );
2829 Ok(())
2830}
2831
2832fn event_queue_collection_contract(queue: &str) -> crate::physical::CollectionContract {
2833 let now = current_unix_ms();
2834 crate::physical::CollectionContract {
2835 name: queue.to_string(),
2836 description: None,
2837 description_generated: false,
2838 declared_model: crate::catalog::CollectionModel::Queue,
2839 schema_mode: crate::catalog::SchemaMode::Dynamic,
2840 origin: crate::physical::ContractOrigin::Implicit,
2841 version: 1,
2842 created_at_unix_ms: now,
2843 updated_at_unix_ms: now,
2844 default_ttl_ms: None,
2845 vector_dimension: None,
2846 vector_metric: None,
2847 context_index_fields: Vec::new(),
2848 declared_columns: Vec::new(),
2849 table_def: None,
2850 timestamps_enabled: false,
2851 context_index_enabled: false,
2852 metrics_raw_retention_ms: None,
2853 metrics_rollup_policies: Vec::new(),
2854 metrics_tenant_identity: None,
2855 metrics_namespace: None,
2856 append_only: true,
2857 subscriptions: Vec::new(),
2858 analytics_config: Vec::new(),
2859 session_key: None,
2860 session_gap_ms: None,
2861 retention_duration_ms: None,
2862 analytical_storage: None,
2863
2864 ai_policy: None,
2865 }
2866}
2867
2868fn column_def_from_ddl(column: &CreateColumnDef) -> RedDBResult<reddb_types::ColumnDef> {
2869 reddb_engine::catalog::ddl::column_def_from_ddl(column).map_err(RedDBError::Query)
2870}
2871
2872fn current_unix_ms() -> u128 {
2873 std::time::SystemTime::now()
2874 .duration_since(std::time::UNIX_EPOCH)
2875 .unwrap_or_default()
2876 .as_millis()
2877}
2878
2879#[cfg(test)]
2880mod tests {
2881 use crate::auth::policies::{ActionPattern, Effect, Policy, ResourcePattern, Statement};
2882 use crate::auth::store::{AuthStore, PrincipalRef};
2883 use crate::auth::UserId;
2884 use crate::auth::{AuthConfig, Role};
2885 use crate::runtime::impl_core::{clear_current_auth_identity, set_current_auth_identity};
2886 use crate::{RedDBOptions, RedDBRuntime};
2887 use reddb_types::Value;
2888 use std::sync::Arc;
2889
2890 fn make_allow_policy(id: &str, action: &str, collection: &str) -> Policy {
2891 Policy {
2892 id: id.to_string(),
2893 version: 1,
2894 tenant: None,
2895 created_at: 0,
2896 updated_at: 0,
2897 statements: vec![Statement {
2898 sid: None,
2899 effect: Effect::Allow,
2900 actions: vec![ActionPattern::Exact(action.to_string())],
2901 resources: vec![ResourcePattern::Exact {
2902 kind: "collection".to_string(),
2903 name: collection.to_string(),
2904 }],
2905 condition: None,
2906 }],
2907 }
2908 }
2909
2910 fn wire_auth_store(rt: &RedDBRuntime) -> Arc<AuthStore> {
2911 let store = Arc::new(AuthStore::new(AuthConfig::default()));
2912 *rt.inner.auth_store.write() = Some(store.clone());
2913 store
2914 }
2915
2916 #[test]
2917 fn drop_denied_without_iam_policy() {
2918 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2919 rt.execute_query("CREATE TABLE foo (id INT)").unwrap();
2920 let store = wire_auth_store(&rt);
2921 let select_only = Policy {
2923 id: "select-only".to_string(),
2924 version: 1,
2925 tenant: None,
2926 created_at: 0,
2927 updated_at: 0,
2928 statements: vec![Statement {
2929 sid: None,
2930 effect: Effect::Allow,
2931 actions: vec![ActionPattern::Exact("select".to_string())],
2932 resources: vec![ResourcePattern::Wildcard],
2933 condition: None,
2934 }],
2935 };
2936 store.put_policy_internal(select_only).unwrap();
2937 let alice = UserId::from_parts(None, "alice");
2938 store
2939 .attach_policy(PrincipalRef::User(alice), "select-only")
2940 .unwrap();
2941 set_current_auth_identity("alice".to_string(), Role::Write);
2942 let err = rt.execute_query("DROP TABLE foo").unwrap_err();
2943 clear_current_auth_identity();
2944 assert!(
2945 format!("{err}").contains("denied by IAM policy"),
2946 "got: {err}"
2947 );
2948 }
2949
2950 #[test]
2951 fn drop_allowed_with_explicit_iam_policy() {
2952 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2953 rt.execute_query("CREATE TABLE bar (id INT)").unwrap();
2954 let store = wire_auth_store(&rt);
2955 let policy = make_allow_policy("allow-drop-bar", "drop", "bar");
2956 store.put_policy_internal(policy).unwrap();
2957 let bob = UserId::from_parts(None, "bob");
2958 store
2959 .attach_policy(PrincipalRef::User(bob), "allow-drop-bar")
2960 .unwrap();
2961 set_current_auth_identity("bob".to_string(), Role::Write);
2962 rt.execute_query("DROP TABLE bar").unwrap();
2963 clear_current_auth_identity();
2964 }
2965
2966 #[test]
2967 fn drop_allowed_with_wildcard_iam_policy() {
2968 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2969 rt.execute_query("CREATE TABLE baz (id INT)").unwrap();
2970 let store = wire_auth_store(&rt);
2971 let policy = Policy {
2972 id: "allow-drop-all".to_string(),
2973 version: 1,
2974 tenant: None,
2975 created_at: 0,
2976 updated_at: 0,
2977 statements: vec![Statement {
2978 sid: None,
2979 effect: Effect::Allow,
2980 actions: vec![ActionPattern::Exact("drop".to_string())],
2981 resources: vec![ResourcePattern::Wildcard],
2982 condition: None,
2983 }],
2984 };
2985 store.put_policy_internal(policy).unwrap();
2986 let carl = UserId::from_parts(None, "carl");
2987 store
2988 .attach_policy(PrincipalRef::User(carl), "allow-drop-all")
2989 .unwrap();
2990 set_current_auth_identity("carl".to_string(), Role::Write);
2991 rt.execute_query("DROP TABLE baz").unwrap();
2992 clear_current_auth_identity();
2993 }
2994
2995 #[test]
2996 fn truncate_denied_without_iam_policy() {
2997 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2998 rt.execute_query("CREATE TABLE qux (id INT)").unwrap();
2999 let store = wire_auth_store(&rt);
3000 store
3007 .set_enforcement_mode(crate::auth::enforcement_mode::PolicyEnforcementMode::PolicyOnly);
3008 let select_only = Policy {
3010 id: "select-only-2".to_string(),
3011 version: 1,
3012 tenant: None,
3013 created_at: 0,
3014 updated_at: 0,
3015 statements: vec![Statement {
3016 sid: None,
3017 effect: Effect::Allow,
3018 actions: vec![ActionPattern::Exact("select".to_string())],
3019 resources: vec![ResourcePattern::Wildcard],
3020 condition: None,
3021 }],
3022 };
3023 store.put_policy_internal(select_only).unwrap();
3024 let dana = UserId::from_parts(None, "dana");
3025 store
3026 .attach_policy(PrincipalRef::User(dana), "select-only-2")
3027 .unwrap();
3028 set_current_auth_identity("dana".to_string(), Role::Write);
3029 let err = rt.execute_query("TRUNCATE TABLE qux").unwrap_err();
3030 clear_current_auth_identity();
3031 assert!(
3032 format!("{err}").contains("denied by IAM policy"),
3033 "got: {err}"
3034 );
3035 }
3036
3037 #[test]
3038 fn truncate_table_clears_rows_and_preserves_schema_and_indexes() {
3039 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3040 rt.execute_query("CREATE TABLE users (id INT, name TEXT)")
3041 .unwrap();
3042 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'ana'), (2, 'bob')")
3043 .unwrap();
3044 rt.execute_query("CREATE INDEX idx_users_id ON users (id) USING HASH")
3045 .unwrap();
3046
3047 let truncated = rt.execute_query("TRUNCATE TABLE users").unwrap();
3048 assert_eq!(truncated.statement_type, "truncate");
3049 assert_eq!(truncated.affected_rows, 0);
3050
3051 let empty = rt.execute_query("SELECT id FROM users").unwrap();
3052 assert!(empty.result.records.is_empty());
3053
3054 rt.execute_query("INSERT INTO users (id, name) VALUES (3, 'cy')")
3055 .unwrap();
3056 let selected = rt
3057 .execute_query("SELECT name FROM users WHERE id = 3")
3058 .unwrap();
3059 let name = selected.result.records[0].get("name").unwrap();
3060 assert_eq!(name, &Value::text("cy"));
3061 assert!(rt.db().collection_contract("users").is_some());
3062 assert!(rt
3063 .inner
3064 .index_store
3065 .list_indices("users")
3066 .iter()
3067 .any(|index| index.name == "idx_users_id"));
3068 }
3069
3070 #[test]
3071 fn truncate_collection_is_polymorphic_and_typed_mismatch_fails() {
3072 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3073 rt.execute_query("CREATE QUEUE tasks").unwrap();
3074 rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3075
3076 let err = rt.execute_query("TRUNCATE TABLE tasks").unwrap_err();
3077 assert!(format!("{err}").contains("model mismatch: expected table, got queue"));
3078
3079 rt.execute_query("TRUNCATE COLLECTION tasks").unwrap();
3080 let len = rt.execute_query("QUEUE LEN tasks").unwrap();
3081 assert_eq!(
3082 len.result.records[0].get("len"),
3083 Some(&Value::UnsignedInteger(0))
3084 );
3085 }
3086
3087 #[test]
3088 fn truncate_system_schema_is_read_only() {
3089 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3090 let err = rt
3091 .execute_query("TRUNCATE COLLECTION red.collections")
3092 .unwrap_err();
3093 assert!(format!("{err}").contains("system schema is read-only"));
3094 }
3095
3096 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3099 let result = rt
3100 .execute_query(&format!("QUEUE PEEK {queue} 100"))
3101 .expect("peek queue");
3102 result
3103 .result
3104 .records
3105 .iter()
3106 .map(
3107 |record| match record.get("payload").expect("payload column") {
3108 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3109 other => panic!("expected JSON queue payload, got {other:?}"),
3110 },
3111 )
3112 .collect()
3113 }
3114
3115 #[test]
3118 fn truncate_event_enabled_table_emits_single_truncate_event() {
3119 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3120 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3121 .unwrap();
3122 rt.execute_query(
3123 "INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')",
3124 )
3125 .unwrap();
3126
3127 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3129
3130 rt.execute_query("TRUNCATE TABLE users").unwrap();
3131
3132 let events = queue_payloads(&rt, "users_events");
3133 assert_eq!(
3135 events.len(),
3136 1,
3137 "expected 1 truncate event, got {}",
3138 events.len()
3139 );
3140 let ev = events[0].as_object().expect("event is object");
3141 assert_eq!(
3142 ev.get("op").and_then(crate::json::Value::as_str),
3143 Some("truncate")
3144 );
3145 assert_eq!(
3146 ev.get("collection").and_then(crate::json::Value::as_str),
3147 Some("users")
3148 );
3149 assert_eq!(
3150 ev.get("entities_count")
3151 .and_then(crate::json::Value::as_u64),
3152 Some(3)
3153 );
3154 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3155 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3156 assert!(ev
3157 .get("event_id")
3158 .and_then(crate::json::Value::as_str)
3159 .is_some_and(|s| !s.is_empty()));
3160 }
3161
3162 #[test]
3164 fn truncate_no_events_collection_emits_nothing() {
3165 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3166 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3167 .unwrap();
3168 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a'), (2, 'b')")
3169 .unwrap();
3170 rt.execute_query("TRUNCATE TABLE plain").unwrap();
3172 let rows = rt.execute_query("SELECT id FROM plain").unwrap();
3174 assert!(rows.result.records.is_empty());
3175 }
3176
3177 #[test]
3181 fn drop_event_enabled_table_emits_single_collection_dropped_event() {
3182 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3183 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3184 .unwrap();
3185 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob')")
3186 .unwrap();
3187
3188 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3190
3191 rt.execute_query("DROP TABLE users").unwrap();
3192
3193 let events = queue_payloads(&rt, "users_events");
3195 assert_eq!(
3196 events.len(),
3197 1,
3198 "expected 1 collection_dropped event, got {}",
3199 events.len()
3200 );
3201 let ev = events[0].as_object().expect("event is object");
3202 assert_eq!(
3203 ev.get("op").and_then(crate::json::Value::as_str),
3204 Some("collection_dropped")
3205 );
3206 assert_eq!(
3207 ev.get("collection").and_then(crate::json::Value::as_str),
3208 Some("users")
3209 );
3210 assert_eq!(
3211 ev.get("final_entities_count")
3212 .and_then(crate::json::Value::as_u64),
3213 Some(2)
3214 );
3215 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3216 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3217 assert!(ev
3218 .get("event_id")
3219 .and_then(crate::json::Value::as_str)
3220 .is_some_and(|s| !s.is_empty()));
3221
3222 let err = rt.execute_query("SELECT id FROM users").unwrap_err();
3224 assert!(
3225 format!("{err}").contains("users"),
3226 "expected not-found error"
3227 );
3228 }
3229
3230 #[test]
3233 fn drop_no_events_collection_emits_nothing() {
3234 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3235 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3236 .unwrap();
3237 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a')")
3238 .unwrap();
3239 rt.execute_query("DROP TABLE plain").unwrap();
3240 let err = rt.execute_query("SELECT id FROM plain").unwrap_err();
3242 assert!(format!("{err}").contains("plain"));
3243 }
3244
3245 #[test]
3249 fn ops_filter_insert_only_ignores_update_and_delete() {
3250 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3251 rt.execute_query(
3252 "CREATE TABLE items (id INT, val TEXT) WITH EVENTS (INSERT) TO items_events",
3253 )
3254 .unwrap();
3255 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 'a')")
3256 .unwrap();
3257 rt.execute_query("UPDATE items SET val = 'b' WHERE id = 1")
3258 .unwrap();
3259 rt.execute_query("DELETE FROM items WHERE id = 1").unwrap();
3260
3261 let events = queue_payloads(&rt, "items_events");
3262 assert_eq!(
3264 events.len(),
3265 1,
3266 "expected 1 insert event, got {}",
3267 events.len()
3268 );
3269 assert_eq!(
3270 events[0]
3271 .as_object()
3272 .unwrap()
3273 .get("op")
3274 .and_then(crate::json::Value::as_str),
3275 Some("insert")
3276 );
3277 }
3278
3279 #[test]
3281 fn where_filter_skips_rows_that_do_not_match() {
3282 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3283 rt.execute_query(
3284 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS WHERE status = 'active' TO users_events",
3285 )
3286 .unwrap();
3287
3288 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active')")
3290 .unwrap();
3291 rt.execute_query("INSERT INTO users (id, status) VALUES (2, 'inactive')")
3293 .unwrap();
3294
3295 let events = queue_payloads(&rt, "users_events");
3296 assert_eq!(
3297 events.len(),
3298 1,
3299 "expected 1 event (only active), got {}",
3300 events.len()
3301 );
3302 let ev = events[0].as_object().unwrap();
3303 assert_eq!(
3304 ev.get("op").and_then(crate::json::Value::as_str),
3305 Some("insert")
3306 );
3307 let after = ev.get("after").unwrap().as_object().unwrap();
3308 assert_eq!(
3309 after.get("status").and_then(crate::json::Value::as_str),
3310 Some("active")
3311 );
3312 }
3313
3314 #[test]
3316 fn ops_filter_and_where_filter_combined() {
3317 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3318 rt.execute_query(
3319 "CREATE TABLE items (id INT, status TEXT) WITH EVENTS (INSERT, UPDATE) WHERE status = 'active' TO items_events",
3320 )
3321 .unwrap();
3322
3323 rt.execute_query("INSERT INTO items (id, status) VALUES (1, 'active')")
3325 .unwrap();
3326 rt.execute_query("INSERT INTO items (id, status) VALUES (2, 'inactive')")
3328 .unwrap();
3329 rt.execute_query("UPDATE items SET status = 'inactive' WHERE id = 1")
3331 .unwrap();
3332 rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3334
3335 let events = queue_payloads(&rt, "items_events");
3336 assert_eq!(
3338 events.len(),
3339 1,
3340 "expected 1 event, got {}: {events:?}",
3341 events.len()
3342 );
3343 assert_eq!(
3344 events[0]
3345 .as_object()
3346 .unwrap()
3347 .get("op")
3348 .and_then(crate::json::Value::as_str),
3349 Some("insert")
3350 );
3351 }
3352
3353 #[test]
3355 fn where_filter_on_delete_checks_before_state() {
3356 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3357 rt.execute_query(
3358 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS (DELETE) WHERE status = 'active' TO users_events",
3359 )
3360 .unwrap();
3361
3362 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active'), (2, 'inactive')")
3363 .unwrap();
3364
3365 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3367 rt.execute_query("DELETE FROM users WHERE id = 2").unwrap();
3369
3370 let events = queue_payloads(&rt, "users_events");
3371 assert_eq!(
3372 events.len(),
3373 1,
3374 "expected 1 delete event, got {}",
3375 events.len()
3376 );
3377 let ev = events[0].as_object().unwrap();
3378 assert_eq!(
3379 ev.get("op").and_then(crate::json::Value::as_str),
3380 Some("delete")
3381 );
3382 }
3383
3384 #[test]
3388 fn alter_add_column_on_event_enabled_table_succeeds() {
3389 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3390 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3391 .unwrap();
3392 rt.execute_query("ALTER TABLE users ADD COLUMN phone TEXT")
3394 .unwrap();
3395 let contract = rt.db().collection_contract("users").unwrap();
3397 assert!(
3398 contract.declared_columns.iter().any(|c| c.name == "phone"),
3399 "phone column should be in contract"
3400 );
3401 assert!(
3403 contract.subscriptions.iter().any(|s| s.enabled),
3404 "subscription should remain enabled"
3405 );
3406 }
3407
3408 #[test]
3411 fn alter_drop_column_and_rls_on_event_enabled_table_succeeds() {
3412 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3413 rt.execute_query(
3414 "CREATE TABLE items (id INT, secret TEXT, status TEXT) WITH EVENTS TO items_events",
3415 )
3416 .unwrap();
3417 rt.execute_query("ALTER TABLE items DROP COLUMN secret")
3419 .unwrap();
3420 let contract = rt.db().collection_contract("items").unwrap();
3421 assert!(
3422 !contract.declared_columns.iter().any(|c| c.name == "secret"),
3423 "secret column should be removed"
3424 );
3425 rt.execute_query("ALTER TABLE items ENABLE ROW LEVEL SECURITY")
3427 .unwrap();
3428 assert!(
3430 contract.subscriptions.iter().any(|s| s.enabled),
3431 "subscription should remain enabled"
3432 );
3433 }
3434
3435 #[test]
3441 fn create_vector_marks_collection_as_turbo_baseline() {
3442 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3443 rt.execute_query("CREATE VECTOR embeddings DIM 4").unwrap();
3444 let store = rt.db().store();
3445 assert!(
3446 crate::runtime::vector_turbo_kind::is_turbo(&store, "embeddings"),
3447 "new vector collections must be turbo-marked baseline"
3448 );
3449 assert!(
3450 rt.db().turbo_state("embeddings").is_some(),
3451 "turbo_state must materialise after CREATE VECTOR"
3452 );
3453 }
3454
3455 #[test]
3459 fn create_table_does_not_mark_turbo() {
3460 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3461 rt.execute_query("CREATE TABLE plain (id INT)").unwrap();
3462 let store = rt.db().store();
3463 assert!(
3464 !crate::runtime::vector_turbo_kind::is_turbo(&store, "plain"),
3465 "non-vector collections must not gain the turbo marker"
3466 );
3467 assert!(rt.db().turbo_state("plain").is_none());
3468 }
3469
3470 #[test]
3473 fn create_collection_kind_vector_turbo_still_marked() {
3474 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3475 rt.execute_query("CREATE COLLECTION turbo_v KIND vector.turbo DIM 4")
3476 .unwrap();
3477 let store = rt.db().store();
3478 assert!(crate::runtime::vector_turbo_kind::is_turbo(
3479 &store, "turbo_v"
3480 ));
3481 assert!(rt.db().turbo_state("turbo_v").is_some());
3482 }
3483
3484 #[test]
3487 fn create_table_persists_and_introspects_ai_policy() {
3488 use crate::catalog::{ModerateDegradedMode, ModerateRejectAction};
3489 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3490 rt.execute_query(
3491 "CREATE TABLE posts (id INT, title TEXT, body TEXT, photo TEXT) WITH ( \
3492 EMBED (fields = ('title', 'body'), provider = 'openai', model = 'text-embedding-3-small'), \
3493 MODERATE (fields = ('body'), provider = 'openai', model = 'omni-moderation-latest', sync = true, degraded = closed, on_reject = flag), \
3494 VISION (image_field = 'photo', outputs = ('caption'), provider = 'openai', model = 'gpt-4o') \
3495 )",
3496 )
3497 .expect("create table with ai policy");
3498
3499 let contracts = rt.db().collection_contracts();
3500 let contract = contracts
3501 .iter()
3502 .find(|c| c.name == "posts")
3503 .expect("contract persisted");
3504 let policy = contract.ai_policy.as_ref().expect("ai policy persisted");
3505
3506 let embed = policy.embed.as_ref().expect("embed");
3507 assert_eq!(embed.fields, vec!["title".to_string(), "body".to_string()]);
3508 assert_eq!(embed.model, "text-embedding-3-small");
3509
3510 let moderate = policy.moderate.as_ref().expect("moderate");
3511 assert!(moderate.sync_gate);
3512 assert_eq!(moderate.degraded_mode, ModerateDegradedMode::Closed);
3513 assert_eq!(moderate.reject_action, ModerateRejectAction::Flag);
3514
3515 let vision = policy.vision.as_ref().expect("vision");
3516 assert_eq!(vision.image_field, "photo");
3517 assert_eq!(vision.model, "gpt-4o");
3518 }
3519
3520 #[test]
3524 fn create_table_rejects_ai_policy_incapable_provider() {
3525 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3526 let err = rt
3527 .execute_query(
3528 "CREATE TABLE bad (id INT, body TEXT) WITH ( \
3529 EMBED (fields = ('body'), provider = 'anthropic', model = 'claude-3-5-sonnet') \
3530 )",
3531 )
3532 .unwrap_err();
3533 assert!(
3534 err.to_string()
3535 .contains("cannot serve the 'embed' modality"),
3536 "{err}"
3537 );
3538 assert!(
3539 rt.db().store().get_collection("bad").is_none(),
3540 "rejected DDL must not create the collection"
3541 );
3542 }
3543
3544 #[test]
3545 fn descriptions_cover_collection_models_and_survive_reopen() {
3546 let directory = tempfile::tempdir().expect("temporary database");
3547 let path = directory.path().join("descriptions.rdb");
3548 let options = RedDBOptions::persistent(&path);
3549 {
3550 let rt = RedDBRuntime::with_options(options.clone()).expect("runtime");
3551 assert!(!rt.config_bool("ai.collection_descriptions.auto_enrich", false));
3552 rt.execute_query("CREATE TABLE contacts (email EMAIL DESCRIPTION 'Contact address', balance MONEY) WITH (DESCRIPTION = 'Contact records')").expect("table");
3553 rt.execute_query(
3554 "CREATE COLLECTION articles KIND document DESCRIPTION 'Published articles'",
3555 )
3556 .expect("document");
3557 rt.execute_query("ALTER COLLECTION articles SET DESCRIPTION 'Editorial articles'")
3558 .expect("alter document");
3559 rt.execute_query(
3560 "ALTER COLLECTION contacts ALTER COLUMN balance SET DESCRIPTION 'Account balance'",
3561 )
3562 .expect("alter column");
3563 let contract = rt
3564 .db()
3565 .collection_contract("contacts")
3566 .expect("table contract");
3567 assert_eq!(contract.description.as_deref(), Some("Contact records"));
3568 assert_eq!(
3569 contract.declared_columns[0].description.as_deref(),
3570 Some("Contact address")
3571 );
3572 assert_eq!(
3573 contract.declared_columns[1].description.as_deref(),
3574 Some("Account balance")
3575 );
3576 assert!(!contract.declared_columns[1].description_generated);
3577 let show_create = rt
3578 .execute_query("SHOW CREATE TABLE contacts")
3579 .expect("show create");
3580 let Some(Value::Text(ddl)) = show_create.result.records[0].get("ddl") else {
3581 panic!("DDL text")
3582 };
3583 assert!(ddl.contains("DESCRIPTION 'Account balance'"), "{ddl}");
3584 assert!(
3585 ddl.contains("WITH (DESCRIPTION = 'Contact records')"),
3586 "{ddl}"
3587 );
3588 let collections = rt
3589 .execute_query("SELECT description FROM red.collections WHERE name = 'contacts'")
3590 .expect("collections view");
3591 assert_eq!(
3592 collections.result.records[0].get("description"),
3593 Some(&Value::text("Contact records"))
3594 );
3595 let columns = rt.execute_query("SELECT description FROM red.columns WHERE collection = 'contacts' AND name = 'email'").expect("columns view");
3596 assert_eq!(
3597 columns.result.records[0].get("description"),
3598 Some(&Value::text("Contact address"))
3599 );
3600 assert!(rt
3601 .execute_query(
3602 "ALTER COLLECTION contacts ALTER COLUMN missing SET DESCRIPTION 'Nope'"
3603 )
3604 .is_err());
3605 rt.checkpoint().expect("checkpoint");
3606 }
3607 let reopened = RedDBRuntime::with_options(options).expect("reopen");
3608 assert_eq!(
3609 reopened
3610 .db()
3611 .collection_contract("articles")
3612 .unwrap()
3613 .description
3614 .as_deref(),
3615 Some("Editorial articles")
3616 );
3617 let contract = reopened.db().collection_contract("contacts").unwrap();
3618 assert_eq!(
3619 contract.declared_columns[1].description.as_deref(),
3620 Some("Account balance")
3621 );
3622 }
3623
3624 #[test]
3625 fn generated_descriptions_preserve_manual_edits_and_reject_stale_versions() {
3626 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3627 rt.execute_query("CREATE TABLE readings (email EMAIL DESCRIPTION 'Manual', amount MONEY)")
3628 .expect("table");
3629 let contract = rt.db().collection_contract("readings").expect("contract");
3630 let suggestions = std::collections::HashMap::from([
3631 ("email".to_string(), "Generated email".to_string()),
3632 ("amount".to_string(), "Generated amount".to_string()),
3633 ]);
3634 assert!(rt
3635 .db()
3636 .save_generated_descriptions_if_version(
3637 "readings",
3638 contract.version,
3639 contract.created_at_unix_ms,
3640 Some("Generated collection".to_string()),
3641 &suggestions
3642 )
3643 .expect("save generated"));
3644 let generated = rt.db().collection_contract("readings").unwrap();
3645 assert_eq!(
3646 generated.declared_columns[0].description.as_deref(),
3647 Some("Manual")
3648 );
3649 assert!(!generated.declared_columns[0].description_generated);
3650 assert_eq!(
3651 generated.declared_columns[1].description.as_deref(),
3652 Some("Generated amount")
3653 );
3654 assert!(generated.declared_columns[1].description_generated);
3655 rt.execute_query(
3656 "ALTER COLLECTION readings ALTER COLUMN amount SET DESCRIPTION 'Manual amount'",
3657 )
3658 .expect("manual override");
3659 assert!(!rt
3660 .db()
3661 .save_generated_descriptions_if_version(
3662 "readings",
3663 generated.version,
3664 generated.created_at_unix_ms,
3665 Some("Stale".to_string()),
3666 &suggestions
3667 )
3668 .expect("stale rejected"));
3669 let updated = rt.db().collection_contract("readings").unwrap();
3670 assert_eq!(
3671 updated.declared_columns[1].description.as_deref(),
3672 Some("Manual amount")
3673 );
3674 assert!(!updated.declared_columns[1].description_generated);
3675 }
3676
3677 #[test]
3678 fn alter_collection_description_works_across_models() {
3679 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3680 for (create, name) in [
3681 ("CREATE TABLE records (id INT)", "records"),
3682 ("CREATE DOCUMENT documents", "documents"),
3683 ("CREATE GRAPH network", "network"),
3684 ("CREATE VECTOR embeddings DIM 3", "embeddings"),
3685 (
3686 "CREATE TIMESERIES measurements RETENTION 90 d",
3687 "measurements",
3688 ),
3689 ("CREATE QUEUE jobs MAX_SIZE 100", "jobs"),
3690 ("CREATE KV settings", "settings"),
3691 ] {
3692 rt.execute_query(create).expect(create);
3693 rt.execute_query(&format!(
3694 "ALTER COLLECTION {name} SET DESCRIPTION 'User description'"
3695 ))
3696 .expect("alter collection");
3697 let contract = rt.db().collection_contract(name).expect("contract");
3698 assert_eq!(
3699 contract.description.as_deref(),
3700 Some("User description"),
3701 "{name}"
3702 );
3703 assert!(!contract.description_generated, "{name}");
3704 }
3705 }
3706}