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 operational_wal_retention_floor(durable_lsn: u64) -> u64 {
21 if durable_lsn == 0 {
22 0
23 } else {
24 1
25 }
26}
27
28fn validate_store_fork_lsn(
29 fork_lsn: u64,
30 retention_floor: u64,
31 durable_lsn: u64,
32) -> RedDBResult<()> {
33 if fork_lsn < retention_floor {
34 return Err(RedDBError::Query(format!(
35 "cannot fork store at LSN {fork_lsn}: below operational WAL retention floor \
36 {retention_floor}; restore from backup for older recovery points"
37 )));
38 }
39 if fork_lsn > durable_lsn {
40 return Err(RedDBError::Query(format!(
41 "cannot fork store at LSN {fork_lsn}: current durable LSN is {durable_lsn}; \
42 restore from backup for recovery points outside the retained WAL window"
43 )));
44 }
45 Ok(())
46}
47
48fn vault_master_key_ref(collection: &str) -> String {
49 format!("red.vault.{collection}.master_key")
50}
51
52impl RedDBRuntime {
53 pub fn execute_create_table(
59 &self,
60 raw_query: &str,
61 query: &CreateTableQuery,
62 ) -> RedDBResult<RuntimeQueryResult> {
63 if query.collection_model != CollectionModel::Table {
64 if query
65 .columns
66 .iter()
67 .any(|column| column.generated.is_some() || column.check.is_some())
68 {
69 return Err(RedDBError::Query(
70 "schema expressions currently require TABLE collections".to_string(),
71 ));
72 }
73 return self.execute_create_keyed_collection(raw_query, query);
74 }
75 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
76 if is_system_schema_name(&query.name) {
77 return Err(RedDBError::Query("system schema is read-only".to_string()));
78 }
79 let store = self.inner.db.store();
80 analyze_create_table(query).map_err(|err| RedDBError::Query(err.to_string()))?;
81 if let Some(policy) = &query.ai_policy {
85 validate_ai_policy_modalities(policy)?;
86 }
87 crate::reserved_fields::ensure_no_reserved_public_item_fields(
88 query.columns.iter().map(|column| column.name.as_str()),
89 &format!("table '{}'", query.name),
90 )?;
91 let exists = store.get_collection(&query.name).is_some();
93 if exists {
94 if query.if_not_exists {
95 return Ok(RuntimeQueryResult::ok_message(
96 raw_query.to_string(),
97 &format!("table '{}' already exists", query.name),
98 "create",
99 ));
100 }
101 return Err(RedDBError::Query(format!(
102 "table '{}' already exists",
103 query.name
104 )));
105 }
106
107 let contract = collection_contract_from_create_table(query)?;
110 crate::application::collection_contract_enforcer::validate_contract_expressions(&contract)?;
111 validate_event_subscriptions(self, &query.name, &contract.subscriptions)?;
112 store
114 .create_collection(&query.name)
115 .map_err(|err| RedDBError::Internal(err.to_string()))?;
116 for subscription in &contract.subscriptions {
117 ensure_event_target_queue(self, &subscription.target_queue)?;
118 }
119 if let Some(default_ttl_ms) = query.default_ttl_ms {
120 self.inner
121 .db
122 .set_collection_default_ttl_ms(&query.name, default_ttl_ms);
123 }
124 self.inner
125 .db
126 .save_collection_contract(contract)
127 .map_err(|err| RedDBError::Internal(err.to_string()))?;
128 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
129 store.set_config_tree(
130 &format!("red.collection_tenants.{}", query.name),
131 &crate::serde_json::Value::String(tenant_id),
132 );
133 }
134 self.inner
135 .db
136 .persist_metadata()
137 .map_err(|err| RedDBError::Internal(err.to_string()))?;
138 self.refresh_table_planner_stats(&query.name);
139 self.invalidate_result_cache();
140 let columns: Vec<String> = query.columns.iter().map(|col| col.name.clone()).collect();
143 self.schema_vocabulary_apply(
144 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
145 collection: query.name.clone(),
146 columns,
147 type_tags: Vec::new(),
148 description: None,
149 },
150 );
151 if let Some(spec) = &query.partition_by {
158 let kind_str = match spec.kind {
159 reddb_rql::ast::PartitionKind::Range => "range",
160 reddb_rql::ast::PartitionKind::List => "list",
161 reddb_rql::ast::PartitionKind::Hash => "hash",
162 };
163 store.set_config_tree(
164 &format!("partition.{}.by", query.name),
165 &crate::serde_json::Value::String(kind_str.to_string()),
166 );
167 store.set_config_tree(
168 &format!("partition.{}.column", query.name),
169 &crate::serde_json::Value::String(spec.column.clone()),
170 );
171 }
172
173 if let Some(col) = &query.tenant_by {
184 store.set_config_tree(
185 &format!("tenant_tables.{}.column", query.name),
186 &crate::serde_json::Value::String(col.clone()),
187 );
188 self.register_tenant_table(&query.name, col);
189 }
190
191 let ttl_suffix = query
192 .default_ttl_ms
193 .map(|ttl_ms| format!(" with default TTL {}ms", ttl_ms))
194 .unwrap_or_default();
195
196 let tenant_suffix = query
197 .tenant_by
198 .as_ref()
199 .map(|col| format!(" (tenant-scoped by {col})"))
200 .unwrap_or_default();
201
202 Ok(RuntimeQueryResult::ok_message(
203 raw_query.to_string(),
204 &format!(
205 "table '{}' created{}{}",
206 query.name, ttl_suffix, tenant_suffix
207 ),
208 "create",
209 ))
210 }
211
212 fn execute_create_keyed_collection(
213 &self,
214 raw_query: &str,
215 query: &CreateTableQuery,
216 ) -> RedDBResult<RuntimeQueryResult> {
217 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
218 if is_system_schema_name(&query.name) {
219 return Err(RedDBError::Query("system schema is read-only".to_string()));
220 }
221 let store = self.inner.db.store();
222 let label = polymorphic_resolver::model_name(query.collection_model);
223 if store.get_collection(&query.name).is_some() {
224 if query.if_not_exists {
225 return Ok(RuntimeQueryResult::ok_message(
226 raw_query.to_string(),
227 &format!("{label} '{}' already exists", query.name),
228 "create",
229 ));
230 }
231 return Err(RedDBError::Query(format!(
232 "{label} '{}' already exists",
233 query.name
234 )));
235 }
236
237 if query.collection_model == CollectionModel::Vault {
238 self.reject_vault_transaction()?;
239 self.provision_vault_key_material(&query.name, query.vault_own_master_key)?;
240 }
241 store
242 .create_collection(&query.name)
243 .map_err(|err| RedDBError::Internal(err.to_string()))?;
244 if query.collection_model == CollectionModel::Vault {
245 let key_scope = if query.vault_own_master_key {
246 "own"
247 } else {
248 "cluster"
249 };
250 store.set_config_tree(
251 &format!("red.vault.{}.key_scope", query.name),
252 &crate::serde_json::Value::String(key_scope.to_string()),
253 );
254 store.set_config_tree(
255 &format!("red.vault.{}.status", query.name),
256 &crate::serde_json::Value::String("sealed".to_string()),
257 );
258 }
259 if query.collection_model == CollectionModel::Metrics {
260 for spec in &query.metrics_rollup_policies {
261 let policy = crate::storage::timeseries::retention::DownsamplePolicy::parse(spec)
262 .ok_or_else(|| {
263 RedDBError::Query(format!("invalid metrics rollup policy '{}'", spec))
264 })?;
265 if policy.source != "raw" {
266 return Err(RedDBError::Query(format!(
267 "invalid metrics rollup policy '{}': metrics v0 rollups must use raw as source",
268 spec
269 )));
270 }
271 if !matches!(
272 policy.aggregation.as_str(),
273 "avg" | "sum" | "min" | "max" | "count"
274 ) {
275 return Err(RedDBError::Query(format!(
276 "invalid metrics rollup policy '{}': supported aggregations are avg, sum, min, max, count",
277 spec
278 )));
279 }
280 }
281 if let Some(raw_retention_ms) = query.default_ttl_ms {
282 self.inner
283 .db
284 .set_collection_default_ttl_ms(&query.name, raw_retention_ms);
285 store.set_config_tree(
286 &format!("red.metrics.{}.raw_retention_ms", query.name),
287 &crate::serde_json::Value::Number(raw_retention_ms as f64),
288 );
289 }
290 let tenant_identity = query
291 .tenant_by
292 .clone()
293 .unwrap_or_else(|| "current_tenant".to_string());
294 store.set_config_tree(
295 &format!("red.metrics.{}.tenant_identity", query.name),
296 &crate::serde_json::Value::String(tenant_identity),
297 );
298 store.set_config_tree(
299 &format!("red.metrics.{}.namespace", query.name),
300 &crate::serde_json::Value::String("default".to_string()),
301 );
302 if !query.metrics_rollup_policies.is_empty() {
303 store.set_config_tree(
304 &format!("red.metrics.{}.rollup_policies", query.name),
305 &crate::serde_json::Value::Array(
306 query
307 .metrics_rollup_policies
308 .iter()
309 .cloned()
310 .map(crate::serde_json::Value::String)
311 .collect(),
312 ),
313 );
314 }
315 }
316 let contract = if query.collection_model == CollectionModel::Metrics {
317 metrics_collection_contract(query)
318 } else {
319 keyed_collection_contract(
320 &query.name,
321 query.collection_model,
322 query.analytics_config.clone(),
323 )
324 };
325 self.inner
326 .db
327 .save_collection_contract(contract)
328 .map_err(|err| RedDBError::Internal(err.to_string()))?;
329 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
330 store.set_config_tree(
331 &format!("red.collection_tenants.{}", query.name),
332 &crate::serde_json::Value::String(tenant_id),
333 );
334 }
335 self.inner
336 .db
337 .persist_metadata()
338 .map_err(|err| RedDBError::Internal(err.to_string()))?;
339 self.invalidate_result_cache();
340
341 Ok(RuntimeQueryResult::ok_message(
342 raw_query.to_string(),
343 &format!("{label} '{}' created", query.name),
344 "create",
345 ))
346 }
347
348 pub fn ensure_system_graph_with_analytics(
357 &self,
358 name: &str,
359 outputs: &[crate::catalog::AnalyticsOutput],
360 ) -> RedDBResult<()> {
361 let store = self.inner.db.store();
362 if store.get_collection(name).is_some() {
363 return Ok(());
364 }
365 let analytics_config = outputs
366 .iter()
367 .map(|output| crate::catalog::AnalyticsViewDescriptor {
368 output: *output,
369 algorithm: None,
370 resolution: None,
371 max_iterations: None,
372 tolerance: None,
373 })
374 .collect();
375 store
376 .create_collection(name)
377 .map_err(|err| RedDBError::Internal(err.to_string()))?;
378 let contract = keyed_collection_contract(
379 name,
380 crate::catalog::CollectionModel::Graph,
381 analytics_config,
382 );
383 self.inner
384 .db
385 .save_collection_contract(contract)
386 .map_err(|err| RedDBError::Internal(err.to_string()))?;
387 self.inner
388 .db
389 .persist_metadata()
390 .map_err(|err| RedDBError::Internal(err.to_string()))?;
391 self.invalidate_result_cache_process_only();
392 Ok(())
393 }
394
395 pub fn execute_create_collection(
396 &self,
397 raw_query: &str,
398 query: &CreateCollectionQuery,
399 ) -> RedDBResult<RuntimeQueryResult> {
400 let model = match query.kind.as_str() {
401 "graph" => CollectionModel::Graph,
402 "document" => CollectionModel::Document,
403 "metrics" => CollectionModel::Metrics,
404 "vector.turbo" => {
405 let dimension = query.vector_dimension.ok_or_else(|| {
406 RedDBError::Query(
407 "CREATE COLLECTION KIND vector.turbo requires DIM".to_string(),
408 )
409 })?;
410 let create = CreateVectorQuery {
411 name: query.name.clone(),
412 dimension,
413 metric: query
414 .vector_metric
415 .unwrap_or(crate::storage::engine::distance::DistanceMetric::Cosine),
416 if_not_exists: query.if_not_exists,
417 };
418 let result = self.execute_create_vector(raw_query, &create)?;
419 let store = self.inner.db.store();
426 crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
427 self.inner
428 .db
429 .persist_metadata()
430 .map_err(|err| RedDBError::Internal(err.to_string()))?;
431 let _ = self.inner.db.turbo_state(&query.name);
436 return Ok(result);
437 }
438 "blockchain" => CollectionModel::Table,
443 other => {
444 return Err(RedDBError::Query(format!(
445 "NOT_YET_SUPPORTED: CREATE COLLECTION KIND {other} is not implemented"
446 )));
447 }
448 };
449 let create = CreateTableQuery {
450 collection_model: model,
451 name: query.name.clone(),
452 columns: Vec::new(),
453 unique_constraints: Vec::new(),
454 if_not_exists: query.if_not_exists,
455 default_ttl_ms: None,
456 metrics_rollup_policies: Vec::new(),
457 context_index_fields: Vec::new(),
458 context_index_enabled: false,
459 timestamps: false,
460 partition_by: None,
461 tenant_by: None,
462 append_only: false,
463 subscriptions: Vec::new(),
464 analytics_config: Vec::new(),
465 vault_own_master_key: false,
466 ai_policy: None,
467 };
468 let result = self.execute_create_table(raw_query, &create)?;
469 if query.kind == "blockchain" {
470 self.install_blockchain_kind(&query.name)?;
471 }
472 if !query.allowed_signers.is_empty() {
477 let actor = crate::runtime::impl_core::current_user_projected()
478 .unwrap_or_else(|| "@system/create-collection".to_string());
479 crate::runtime::signed_writes_kind::try_install(
480 &self.inner.db.store(),
481 &query.name,
482 &query.allowed_signers,
483 &actor,
484 )?;
485 }
486 Ok(result)
487 }
488
489 fn install_blockchain_kind(&self, name: &str) -> RedDBResult<()> {
493 use crate::runtime::blockchain_kind;
494 use crate::storage::unified::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
495 use std::sync::Arc;
496
497 let store = self.inner.db.store();
498 blockchain_kind::try_mark_as_chain(&store, name)?;
499
500 let existing_tip = blockchain_kind::try_chain_tip(&store, name)?;
501 if existing_tip.height.is_some() {
502 return Ok(());
503 }
504
505 let fields = blockchain_kind::genesis_fields(blockchain_kind::now_ms());
506 let named: std::collections::HashMap<String, reddb_types::Value> =
507 fields.into_iter().collect();
508 let entity = UnifiedEntity::new(
509 EntityId::new(0),
510 EntityKind::TableRow {
511 table: Arc::from(name),
512 row_id: 0,
513 },
514 EntityData::Row(RowData {
515 columns: Vec::new(),
516 named: Some(named),
517 schema: None,
518 }),
519 );
520 store
521 .insert_auto(name, entity)
522 .map_err(|err| RedDBError::Internal(err.to_string()))?;
523 if let Some(tip) = blockchain_kind::try_chain_tip_full(&store, name)? {
526 self.inner
527 .chain_tip_cache
528 .lock()
529 .insert(name.to_string(), tip);
530 }
531 Ok(())
532 }
533
534 pub fn execute_create_vector(
535 &self,
536 raw_query: &str,
537 query: &CreateVectorQuery,
538 ) -> RedDBResult<RuntimeQueryResult> {
539 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
540 if is_system_schema_name(&query.name) {
541 return Err(RedDBError::Query("system schema is read-only".to_string()));
542 }
543 let store = self.inner.db.store();
544 if store.get_collection(&query.name).is_some() {
545 if query.if_not_exists {
546 return Ok(RuntimeQueryResult::ok_message(
547 raw_query.to_string(),
548 &format!("vector '{}' already exists", query.name),
549 "create",
550 ));
551 }
552 return Err(RedDBError::Query(format!(
553 "vector '{}' already exists",
554 query.name
555 )));
556 }
557
558 store
559 .create_collection(&query.name)
560 .map_err(|err| RedDBError::Internal(err.to_string()))?;
561 self.inner
562 .db
563 .save_collection_contract(vector_collection_contract(query))
564 .map_err(|err| RedDBError::Internal(err.to_string()))?;
565 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
566 store.set_config_tree(
567 &format!("red.collection_tenants.{}", query.name),
568 &crate::serde_json::Value::String(tenant_id),
569 );
570 }
571 crate::runtime::vector_turbo_kind::mark_as_turbo(&store, &query.name);
580 self.inner
581 .db
582 .persist_metadata()
583 .map_err(|err| RedDBError::Internal(err.to_string()))?;
584 let _ = self.inner.db.turbo_state(&query.name);
589 self.invalidate_result_cache();
590
591 Ok(RuntimeQueryResult::ok_message(
592 raw_query.to_string(),
593 &format!("vector '{}' created", query.name),
594 "create",
595 ))
596 }
597
598 fn provision_vault_key_material(
599 &self,
600 collection: &str,
601 own_master_key: bool,
602 ) -> RedDBResult<()> {
603 let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
604 RedDBError::Query("CREATE VAULT requires an enabled, unsealed vault".to_string())
605 })?;
606 if !auth_store.is_vault_backed() {
607 return Err(RedDBError::Query(
608 "CREATE VAULT requires an enabled, unsealed vault".to_string(),
609 ));
610 }
611
612 auth_store
613 .ensure_vault_secret_key_result()
614 .map_err(|err| RedDBError::Query(err.to_string()))?;
615
616 if own_master_key
617 && auth_store
618 .vault_kv_get(&vault_master_key_ref(collection))
619 .is_none()
620 {
621 let key = crate::auth::store::random_bytes(32);
622 auth_store
623 .vault_kv_try_set(vault_master_key_ref(collection), hex::encode(key))
624 .map_err(|err| RedDBError::Query(err.to_string()))?;
625 }
626
627 Ok(())
628 }
629
630 pub fn execute_drop_table(
634 &self,
635 raw_query: &str,
636 query: &DropTableQuery,
637 ) -> RedDBResult<RuntimeQueryResult> {
638 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
639 let store = self.inner.db.store();
640
641 if is_system_schema_name(&query.name) {
642 return Err(RedDBError::Query("system schema is read-only".to_string()));
643 }
644
645 let exists = store.get_collection(&query.name).is_some();
646 if !exists {
647 if query.if_exists {
648 return Ok(RuntimeQueryResult::ok_message(
649 raw_query.to_string(),
650 &format!("table '{}' does not exist", query.name),
651 "drop",
652 ));
653 }
654 return Err(RedDBError::NotFound(format!(
655 "table '{}' not found",
656 query.name
657 )));
658 }
659 let actual =
660 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
661 polymorphic_resolver::ensure_model_match(CollectionModel::Table, actual)?;
662
663 let final_count = store
666 .get_collection(&query.name)
667 .map(|manager| manager.query_all(|_| true).len() as u64)
668 .unwrap_or(0);
669 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
670 self,
671 &query.name,
672 final_count,
673 )?;
674
675 let topology_lock = self.inner.index_store.collection_topology_lock(&query.name);
676 let _topology_guard = topology_lock.write();
677 let orphaned_indices: Vec<String> = self
678 .inner
679 .index_store
680 .list_indices(&query.name)
681 .into_iter()
682 .map(|index| index.name)
683 .collect();
684 for name in &orphaned_indices {
685 self.inner.index_store.drop_index(name, &query.name);
686 }
687
688 store
689 .drop_collection(&query.name)
690 .map_err(|err| RedDBError::Internal(err.to_string()))?;
691 self.inner.db.invalidate_vector_index(&query.name);
692 self.inner.db.clear_collection_default_ttl_ms(&query.name);
693 self.inner
694 .db
695 .remove_collection_contract(&query.name)
696 .map_err(|err| RedDBError::Internal(err.to_string()))?;
697 self.clear_table_planner_stats(&query.name);
698 self.invalidate_result_cache();
699 if let Some(store) = self.inner.auth_store.read().clone() {
703 store.invalidate_visible_collections_cache();
704 }
705 self.schema_vocabulary_apply(
714 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
715 collection: query.name.clone(),
716 },
717 );
718
719 Ok(RuntimeQueryResult::ok_message(
720 raw_query.to_string(),
721 &format!("table '{}' dropped", query.name),
722 "drop",
723 ))
724 }
725
726 pub fn execute_drop_graph(
727 &self,
728 raw_query: &str,
729 query: &DropGraphQuery,
730 ) -> RedDBResult<RuntimeQueryResult> {
731 self.execute_drop_typed_collection(
732 raw_query,
733 &query.name,
734 query.if_exists,
735 CollectionModel::Graph,
736 "graph",
737 )
738 }
739
740 pub fn execute_drop_vector(
741 &self,
742 raw_query: &str,
743 query: &DropVectorQuery,
744 ) -> RedDBResult<RuntimeQueryResult> {
745 self.execute_drop_typed_collection(
746 raw_query,
747 &query.name,
748 query.if_exists,
749 CollectionModel::Vector,
750 "vector",
751 )
752 }
753
754 pub fn execute_drop_document(
755 &self,
756 raw_query: &str,
757 query: &DropDocumentQuery,
758 ) -> RedDBResult<RuntimeQueryResult> {
759 self.execute_drop_typed_collection(
760 raw_query,
761 &query.name,
762 query.if_exists,
763 CollectionModel::Document,
764 "document",
765 )
766 }
767
768 pub fn execute_drop_kv(
769 &self,
770 raw_query: &str,
771 query: &DropKvQuery,
772 ) -> RedDBResult<RuntimeQueryResult> {
773 let label = polymorphic_resolver::model_name(query.model);
774 self.execute_drop_typed_collection(
775 raw_query,
776 &query.name,
777 query.if_exists,
778 query.model,
779 label,
780 )
781 }
782
783 pub fn execute_drop_collection(
784 &self,
785 raw_query: &str,
786 query: &DropCollectionQuery,
787 ) -> RedDBResult<RuntimeQueryResult> {
788 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
789 if is_system_schema_name(&query.name) {
790 return Err(RedDBError::Query("system schema is read-only".to_string()));
791 }
792 let store = self.inner.db.store();
793 if store.get_collection(&query.name).is_none() {
794 if query.if_exists {
795 return Ok(RuntimeQueryResult::ok_message(
796 raw_query.to_string(),
797 &format!("collection '{}' does not exist", query.name),
798 "drop",
799 ));
800 }
801 return Err(RedDBError::NotFound(format!(
802 "collection '{}' not found",
803 query.name
804 )));
805 }
806
807 let actual =
808 polymorphic_resolver::resolve(&query.name, &self.inner.db.catalog_model_snapshot())?;
809 if let Some(expected) = query.model {
810 polymorphic_resolver::ensure_model_match(expected, actual)?;
811 }
812
813 match actual {
814 CollectionModel::Table => self.execute_drop_table(
815 raw_query,
816 &DropTableQuery {
817 name: query.name.clone(),
818 if_exists: query.if_exists,
819 },
820 ),
821 CollectionModel::TimeSeries => self.execute_drop_timeseries(
822 raw_query,
823 &DropTimeSeriesQuery {
824 name: query.name.clone(),
825 if_exists: query.if_exists,
826 },
827 ),
828 CollectionModel::Queue => self.execute_drop_queue(
829 raw_query,
830 &DropQueueQuery {
831 name: query.name.clone(),
832 if_exists: query.if_exists,
833 },
834 ),
835 CollectionModel::Graph => self.execute_drop_graph(
836 raw_query,
837 &DropGraphQuery {
838 name: query.name.clone(),
839 if_exists: query.if_exists,
840 },
841 ),
842 CollectionModel::Vector => self.execute_drop_vector(
843 raw_query,
844 &DropVectorQuery {
845 name: query.name.clone(),
846 if_exists: query.if_exists,
847 },
848 ),
849 CollectionModel::Document => self.execute_drop_document(
850 raw_query,
851 &DropDocumentQuery {
852 name: query.name.clone(),
853 if_exists: query.if_exists,
854 },
855 ),
856 CollectionModel::Kv => self.execute_drop_kv(
857 raw_query,
858 &DropKvQuery {
859 name: query.name.clone(),
860 if_exists: query.if_exists,
861 model: CollectionModel::Kv,
862 },
863 ),
864 CollectionModel::Config => self.execute_drop_kv(
865 raw_query,
866 &DropKvQuery {
867 name: query.name.clone(),
868 if_exists: query.if_exists,
869 model: CollectionModel::Config,
870 },
871 ),
872 CollectionModel::Vault => self.execute_drop_kv(
873 raw_query,
874 &DropKvQuery {
875 name: query.name.clone(),
876 if_exists: query.if_exists,
877 model: CollectionModel::Vault,
878 },
879 ),
880 CollectionModel::Hll => self.execute_probabilistic_command(
881 raw_query,
882 &ProbabilisticCommand::DropHll {
883 name: query.name.clone(),
884 if_exists: query.if_exists,
885 },
886 ),
887 CollectionModel::Sketch => self.execute_probabilistic_command(
888 raw_query,
889 &ProbabilisticCommand::DropSketch {
890 name: query.name.clone(),
891 if_exists: query.if_exists,
892 },
893 ),
894 CollectionModel::Filter => self.execute_probabilistic_command(
895 raw_query,
896 &ProbabilisticCommand::DropFilter {
897 name: query.name.clone(),
898 if_exists: query.if_exists,
899 },
900 ),
901 CollectionModel::Metrics => self.execute_drop_typed_collection(
902 raw_query,
903 &query.name,
904 query.if_exists,
905 CollectionModel::Metrics,
906 "metrics",
907 ),
908 CollectionModel::Mixed => self.execute_drop_typed_collection(
909 raw_query,
910 &query.name,
911 query.if_exists,
912 CollectionModel::Mixed,
913 "collection",
914 ),
915 }
916 }
917
918 fn require_graph_for_analytics(&self, name: &str) -> RedDBResult<()> {
924 let is_graph = self
925 .inner
926 .db
927 .collection_contract(name)
928 .map(|c| c.declared_model == crate::catalog::CollectionModel::Graph)
929 .unwrap_or(false);
930 if !is_graph {
931 return Err(RedDBError::Query(format!(
932 "ALTER GRAPH ... ANALYTICS: '{name}' is not a graph collection"
933 )));
934 }
935 Ok(())
936 }
937
938 pub fn execute_alter_table(
944 &self,
945 raw_query: &str,
946 query: &AlterTableQuery,
947 ) -> RedDBResult<RuntimeQueryResult> {
948 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
949 let expression_contract =
950 self.db()
951 .collection_contract(&query.name)
952 .is_some_and(|contract| {
953 crate::application::collection_contract_enforcer::has_contract_expressions(
954 &contract,
955 )
956 });
957 if query.operations.iter().any(|operation| match operation {
958 AlterOperation::AddColumn(column) => {
959 expression_contract || column.generated.is_some() || column.check.is_some()
960 }
961 AlterOperation::DropColumn(_) | AlterOperation::RenameColumn { .. } => {
962 expression_contract
963 }
964 _ => false,
965 }) {
966 return Err(RedDBError::Query("ALTER of expression schemas requires validated backfill; create a new collection and import explicitly".to_string()));
967 }
968
969 let store = self.inner.db.store();
970
971 if store.get_collection(&query.name).is_none() {
973 return Err(RedDBError::NotFound(format!(
974 "table '{}' not found",
975 query.name
976 )));
977 }
978
979 let mut messages = Vec::new();
980
981 let fields_added: Vec<String> = query
983 .operations
984 .iter()
985 .filter_map(|op| {
986 if let AlterOperation::AddColumn(col) = op {
987 Some(col.name.clone())
988 } else {
989 None
990 }
991 })
992 .collect();
993 let fields_removed: Vec<String> = query
994 .operations
995 .iter()
996 .filter_map(|op| {
997 if let AlterOperation::DropColumn(name) = op {
998 Some(name.clone())
999 } else {
1000 None
1001 }
1002 })
1003 .collect();
1004
1005 for op in &query.operations {
1006 match op {
1007 AlterOperation::AddColumn(col) => {
1008 messages.push(format!("column '{}' added", col.name));
1010 }
1011 AlterOperation::DropColumn(name) => {
1012 messages.push(format!("column '{}' dropped", name));
1013 }
1014 AlterOperation::RenameColumn { from, to } => {
1015 messages.push(format!("column '{}' renamed to '{}'", from, to));
1016 }
1017 AlterOperation::AttachPartition { child, bound } => {
1018 store.set_config_tree(
1022 &format!("partition.{}.children.{}", query.name, child),
1023 &crate::serde_json::Value::String(bound.clone()),
1024 );
1025 messages.push(format!(
1026 "partition '{child}' attached to '{}' ({bound})",
1027 query.name
1028 ));
1029 }
1030 AlterOperation::DetachPartition { child } => {
1031 store.set_config_tree(
1032 &format!("partition.{}.children.{}", query.name, child),
1033 &crate::serde_json::Value::Null,
1034 );
1035 messages.push(format!(
1036 "partition '{child}' detached from '{}'",
1037 query.name
1038 ));
1039 }
1040 AlterOperation::EnableRowLevelSecurity => {
1041 self.inner
1042 .rls_enabled_tables
1043 .write()
1044 .insert(query.name.clone());
1045 store.set_config_tree(
1047 &format!("rls.enabled.{}", query.name),
1048 &crate::serde_json::Value::Bool(true),
1049 );
1050 self.invalidate_plan_cache();
1051 messages.push(format!("row level security enabled on '{}'", query.name));
1052 self.invalidate_result_cache();
1053 }
1054 AlterOperation::DisableRowLevelSecurity => {
1055 self.inner.rls_enabled_tables.write().remove(&query.name);
1056 store.set_config_tree(
1057 &format!("rls.enabled.{}", query.name),
1058 &crate::serde_json::Value::Null,
1059 );
1060 self.invalidate_plan_cache();
1061 messages.push(format!("row level security disabled on '{}'", query.name));
1062 self.invalidate_result_cache();
1063 }
1064 AlterOperation::EnableTenancy { column } => {
1066 store.set_config_tree(
1067 &format!("tenant_tables.{}.column", query.name),
1068 &crate::serde_json::Value::String(column.clone()),
1069 );
1070 self.register_tenant_table(&query.name, column);
1071 self.invalidate_plan_cache();
1072 messages.push(format!(
1073 "tenancy enabled on '{}' by column '{column}'",
1074 query.name
1075 ));
1076 }
1077 AlterOperation::DisableTenancy => {
1078 store.set_config_tree(
1079 &format!("tenant_tables.{}.column", query.name),
1080 &crate::serde_json::Value::Null,
1081 );
1082 self.unregister_tenant_table(&query.name);
1083 self.invalidate_plan_cache();
1084 messages.push(format!("tenancy disabled on '{}'", query.name));
1085 }
1086 AlterOperation::SetAppendOnly(on) => {
1087 messages.push(format!(
1092 "append_only {} on '{}'",
1093 if *on { "enabled" } else { "disabled" },
1094 query.name
1095 ));
1096 }
1097 AlterOperation::SetVersioned(on) => {
1098 self.vcs_set_versioned(&query.name, *on)?;
1105 messages.push(format!(
1106 "versioned {} on '{}'",
1107 if *on { "enabled" } else { "disabled" },
1108 query.name
1109 ));
1110 }
1111 AlterOperation::EnableEvents(subscription) => {
1112 let mut subscription = subscription.clone();
1113 subscription.source = query.name.clone();
1114 validate_event_subscriptions(
1115 self,
1116 &query.name,
1117 std::slice::from_ref(&subscription),
1118 )?;
1119 ensure_event_target_queue(self, &subscription.target_queue)?;
1120 messages.push(format!(
1121 "events enabled on '{}' to '{}'",
1122 query.name, subscription.target_queue
1123 ));
1124 }
1125 AlterOperation::DisableEvents => {
1126 messages.push(format!("events disabled on '{}'", query.name));
1127 }
1128 AlterOperation::AddSubscription { name, descriptor } => {
1129 let mut sub = descriptor.clone();
1130 sub.name = name.clone();
1131 sub.source = query.name.clone();
1132 validate_event_subscriptions(self, &query.name, std::slice::from_ref(&sub))?;
1133 ensure_event_target_queue(self, &sub.target_queue)?;
1134 messages.push(format!(
1135 "subscription '{}' added on '{}' to '{}'",
1136 name, query.name, sub.target_queue
1137 ));
1138 }
1139 AlterOperation::DropSubscription { name } => {
1140 messages.push(format!(
1141 "subscription '{}' dropped on '{}'",
1142 name, query.name
1143 ));
1144 }
1145 AlterOperation::AddSigner { pubkey } => {
1146 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1153 return Err(RedDBError::Query(format!(
1154 "ALTER COLLECTION ADD SIGNER: '{}' has no signer registry; \
1155 recreate it with CREATE COLLECTION ... SIGNED_BY (...)",
1156 query.name
1157 )));
1158 }
1159 let actor = crate::runtime::impl_core::current_user_projected()
1160 .unwrap_or_else(|| "@system/alter".to_string());
1161 let changed = crate::runtime::signed_writes_kind::try_add_signer(
1162 &store,
1163 &query.name,
1164 *pubkey,
1165 &actor,
1166 )?;
1167 messages.push(format!(
1168 "signer {} on '{}'",
1169 if changed { "added" } else { "already present" },
1170 query.name
1171 ));
1172 }
1173 AlterOperation::RevokeSigner { pubkey } => {
1174 if !crate::runtime::signed_writes_kind::try_is_signed(&store, &query.name)? {
1175 return Err(RedDBError::Query(format!(
1176 "ALTER COLLECTION REVOKE SIGNER: '{}' has no signer registry",
1177 query.name
1178 )));
1179 }
1180 let actor = crate::runtime::impl_core::current_user_projected()
1181 .unwrap_or_else(|| "@system/alter".to_string());
1182 let changed = crate::runtime::signed_writes_kind::try_revoke_signer(
1183 &store,
1184 &query.name,
1185 pubkey,
1186 &actor,
1187 )?;
1188 messages.push(format!(
1189 "signer {} on '{}'",
1190 if changed {
1191 "revoked"
1192 } else {
1193 "already revoked"
1194 },
1195 query.name
1196 ));
1197 }
1198 AlterOperation::SetRetention { duration_ms } => {
1199 let existing = self.inner.db.collection_contract(&query.name);
1205 let has_ts_column = existing
1206 .as_ref()
1207 .map(retention_timestamp_column_exists)
1208 .unwrap_or(false);
1209 if !has_ts_column {
1210 return Err(RedDBError::Query(format!(
1211 "ALTER COLLECTION SET RETENTION: '{}' has no timestamp \
1212 column — declare a TIMESTAMP/TIMESTAMPMS/DATETIME column \
1213 or enable WITH timestamps = true before setting a \
1214 retention policy",
1215 query.name
1216 )));
1217 }
1218 messages.push(format!(
1219 "retention set to {duration_ms} ms on '{}'",
1220 query.name
1221 ));
1222 }
1223 AlterOperation::UnsetRetention => {
1224 messages.push(format!("retention cleared on '{}'", query.name));
1225 }
1226 AlterOperation::AddAnalytics(views) => {
1227 self.require_graph_for_analytics(&query.name)?;
1232 let existing = self.inner.db.collection_contract(&query.name);
1233 let enabled: std::collections::BTreeSet<crate::catalog::AnalyticsOutput> =
1234 existing
1235 .as_ref()
1236 .map(|c| c.analytics_config.iter().map(|v| v.output).collect())
1237 .unwrap_or_default();
1238 for view in views {
1239 if enabled.contains(&view.output) {
1240 messages.push(format!(
1243 "analytics '{}' already enabled on '{}'",
1244 view.output.as_str(),
1245 query.name
1246 ));
1247 } else {
1248 messages.push(format!(
1249 "analytics '{}' enabled on '{}'",
1250 view.output.as_str(),
1251 query.name
1252 ));
1253 }
1254 }
1255 }
1256 AlterOperation::DropAnalytics(output) => {
1257 self.require_graph_for_analytics(&query.name)?;
1258 let enabled = self
1259 .inner
1260 .db
1261 .collection_contract(&query.name)
1262 .map(|c| c.analytics_config.iter().any(|v| v.output == *output))
1263 .unwrap_or(false);
1264 if !enabled {
1265 return Err(RedDBError::Query(format!(
1268 "ALTER GRAPH DROP ANALYTICS: analytics output '{}' is not enabled on graph '{}'",
1269 output.as_str(),
1270 query.name
1271 )));
1272 }
1273 messages.push(format!(
1274 "analytics '{}' disabled on '{}'",
1275 output.as_str(),
1276 query.name
1277 ));
1278 }
1279 }
1280 }
1281
1282 let mut contract = self
1283 .inner
1284 .db
1285 .collection_contract(&query.name)
1286 .unwrap_or_else(|| default_collection_contract_for_existing_table(&query.name));
1287 apply_alter_operations_to_contract(&mut contract, &query.operations);
1288 contract.version = contract.version.saturating_add(1);
1289 contract.updated_at_unix_ms = current_unix_ms();
1290 self.inner
1291 .db
1292 .save_collection_contract(contract)
1293 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1294 if !fields_added.is_empty() || !fields_removed.is_empty() {
1298 let sub_names: Vec<String> = self
1299 .inner
1300 .db
1301 .collection_contract(&query.name)
1302 .map(|c| {
1303 c.subscriptions
1304 .iter()
1305 .filter(|s| s.enabled)
1306 .map(|s| s.name.clone())
1307 .collect()
1308 })
1309 .unwrap_or_default();
1310 if !sub_names.is_empty() {
1311 crate::telemetry::operator_event::OperatorEvent::SubscriptionSchemaChange {
1312 collection: query.name.clone(),
1313 subscription_names: sub_names.join(", "),
1314 fields_added: fields_added.join(", "),
1315 fields_removed: fields_removed.join(", "),
1316 lsn: self.cdc_current_lsn(),
1317 }
1318 .emit_global();
1319 }
1320 }
1321
1322 self.clear_table_planner_stats(&query.name);
1323 self.invalidate_result_cache();
1324 let post_alter_columns: Vec<String> = self
1329 .inner
1330 .db
1331 .collection_contract(&query.name)
1332 .map(|contract| {
1333 contract
1334 .declared_columns
1335 .iter()
1336 .map(|col| col.name.clone())
1337 .collect()
1338 })
1339 .unwrap_or_default();
1340 self.schema_vocabulary_apply(
1341 crate::runtime::schema_vocabulary::DdlEvent::AlterCollection {
1342 collection: query.name.clone(),
1343 columns: post_alter_columns,
1344 type_tags: Vec::new(),
1345 description: None,
1346 },
1347 );
1348
1349 let message = if messages.is_empty() {
1350 format!("table '{}' altered (no operations)", query.name)
1351 } else {
1352 format!("table '{}' altered: {}", query.name, messages.join(", "))
1353 };
1354
1355 Ok(RuntimeQueryResult::ok_message(
1356 raw_query.to_string(),
1357 &message,
1358 "alter",
1359 ))
1360 }
1361
1362 pub fn execute_create_vcs_ref(
1363 &self,
1364 raw_query: &str,
1365 query: &CreateVcsRefQuery,
1366 ) -> RedDBResult<RuntimeQueryResult> {
1367 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1368 let connection_id = crate::runtime::impl_core::current_connection_id();
1369 match query.kind {
1370 VcsRefKind::Branch => {
1371 self.vcs_branch_create(crate::application::vcs::CreateBranchInput {
1372 name: query.name.clone(),
1373 from: query.target.clone(),
1374 connection_id,
1375 })?;
1376 }
1377 VcsRefKind::Tag => {
1378 let target = match &query.target {
1379 Some(target) => target.clone(),
1380 None => self
1381 .vcs_status(crate::application::vcs::StatusInput { connection_id })?
1382 .head_commit
1383 .filter(|head| !head.is_empty())
1384 .ok_or_else(|| {
1385 RedDBError::InvalidConfig(
1386 "cannot create tag: HEAD has no commits".to_string(),
1387 )
1388 })?,
1389 };
1390 self.vcs_tag_create(crate::application::vcs::CreateTagInput {
1391 name: query.name.clone(),
1392 target,
1393 annotation: None,
1394 })?;
1395 }
1396 }
1397 let kind = match query.kind {
1398 VcsRefKind::Branch => "branch",
1399 VcsRefKind::Tag => "tag",
1400 };
1401 Ok(RuntimeQueryResult::ok_message(
1402 raw_query.to_string(),
1403 &format!("{kind} '{}' created", query.name),
1404 "create",
1405 ))
1406 }
1407
1408 pub fn execute_drop_vcs_ref(
1409 &self,
1410 raw_query: &str,
1411 query: &DropVcsRefQuery,
1412 ) -> RedDBResult<RuntimeQueryResult> {
1413 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1414 match query.kind {
1415 VcsRefKind::Branch => self.vcs_branch_delete(&query.name)?,
1416 VcsRefKind::Tag => self.vcs_tag_delete(&query.name)?,
1417 }
1418 let kind = match query.kind {
1419 VcsRefKind::Branch => "branch",
1420 VcsRefKind::Tag => "tag",
1421 };
1422 Ok(RuntimeQueryResult::ok_message(
1423 raw_query.to_string(),
1424 &format!("{kind} '{}' dropped", query.name),
1425 "drop",
1426 ))
1427 }
1428
1429 pub fn execute_fork_store(
1430 &self,
1431 raw_query: &str,
1432 query: &ForkStoreQuery,
1433 ) -> RedDBResult<RuntimeQueryResult> {
1434 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1435 let path = self.inner.db.path().ok_or_else(|| {
1436 RedDBError::Query("FORK STORE requires a persistent store".to_string())
1437 })?;
1438 let durable_lsn = self.cdc_current_lsn();
1439 let fork_lsn = query.at_lsn.unwrap_or(durable_lsn);
1440 let retention_floor = operational_wal_retention_floor(durable_lsn);
1441 validate_store_fork_lsn(fork_lsn, retention_floor, durable_lsn)?;
1442
1443 let manifest = reddb_file::OperationalManifest::for_db_path(path);
1444 let mut collections = self.inner.db.store().list_collections();
1445 collections.sort();
1446 manifest.recover_or_bootstrap(&collections).map_err(|err| {
1447 RedDBError::Query(format!("failed to prepare store fork manifest: {err}"))
1448 })?;
1449 manifest
1450 .create_fork(&query.name, fork_lsn)
1451 .map_err(|err| RedDBError::Query(format!("failed to create store fork: {err}")))?;
1452 Ok(RuntimeQueryResult::ok_message(
1453 raw_query.to_string(),
1454 &format!("store fork '{}' created at LSN {fork_lsn}", query.name),
1455 "fork_store",
1456 ))
1457 }
1458
1459 pub fn execute_drop_fork(
1460 &self,
1461 raw_query: &str,
1462 query: &DropForkQuery,
1463 ) -> RedDBResult<RuntimeQueryResult> {
1464 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1465 let path = self.inner.db.path().ok_or_else(|| {
1466 RedDBError::Query("DROP FORK requires a persistent store".to_string())
1467 })?;
1468 let manifest = reddb_file::OperationalManifest::for_db_path(path);
1469 let dropped = manifest
1470 .drop_fork(&query.name)
1471 .map_err(|err| RedDBError::Query(format!("failed to drop store fork: {err}")))?;
1472 if !dropped && !query.if_exists {
1473 return Err(RedDBError::NotFound(format!("store fork '{}'", query.name)));
1474 }
1475 Ok(RuntimeQueryResult::ok_message(
1476 raw_query.to_string(),
1477 &format!("store fork '{}' dropped", query.name),
1478 "drop_fork",
1479 ))
1480 }
1481
1482 pub fn execute_promote_fork(
1483 &self,
1484 raw_query: &str,
1485 query: &PromoteForkQuery,
1486 ) -> RedDBResult<RuntimeQueryResult> {
1487 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
1488 let path = self.inner.db.path().ok_or_else(|| {
1489 RedDBError::Query("PROMOTE FORK requires a persistent store".to_string())
1490 })?;
1491 self.flush()?;
1492 let manifest = reddb_file::OperationalManifest::for_db_path(path);
1493 let outcome = manifest
1494 .promote_fork(&query.name)
1495 .map_err(|err| RedDBError::Query(format!("failed to promote store fork: {err}")))?;
1496 let outcome =
1497 outcome.ok_or_else(|| RedDBError::NotFound(format!("store fork '{}'", query.name)))?;
1498 Ok(RuntimeQueryResult::ok_message(
1499 raw_query.to_string(),
1500 &format!(
1501 "store fork '{}' promoted at LSN {}; retired parent archived at {}",
1502 outcome.name,
1503 outcome.fork_lsn,
1504 outcome.archived_parent.store_identity()
1505 ),
1506 "promote_fork",
1507 ))
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 store = self.inner.db.store();
1970 let Some(manager) = store.get_collection(name) else {
1971 return Ok(0);
1972 };
1973 let entities = manager.query_all(|_| true);
1974 if entities.is_empty() {
1975 return Ok(0);
1976 }
1977
1978 for entity in &entities {
1979 let fields = entity_index_fields(&entity.data);
1980 self.inner
1981 .index_store
1982 .index_entity_delete(name, entity.id, &fields)
1983 .map_err(RedDBError::Internal)?;
1984 }
1985
1986 let ids = entities.iter().map(|entity| entity.id).collect::<Vec<_>>();
1987 let deleted_ids = store
1988 .delete_batch(name, &ids)
1989 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1990 for id in &deleted_ids {
1991 store.context_index().remove_entity(*id);
1992 }
1993 Ok(deleted_ids.len() as u64)
1994 }
1995
1996 fn drop_collection_storage(
1997 &self,
1998 raw_query: &str,
1999 name: &str,
2000 label: &str,
2001 ) -> RedDBResult<RuntimeQueryResult> {
2002 let store = self.inner.db.store();
2003
2004 let final_count = store
2007 .get_collection(name)
2008 .map(|manager| manager.query_all(|_| true).len() as u64)
2009 .unwrap_or(0);
2010 crate::runtime::mutation::emit_collection_dropped_event_for_collection(
2011 self,
2012 name,
2013 final_count,
2014 )?;
2015
2016 let topology_lock = self.inner.index_store.collection_topology_lock(name);
2017 let _topology_guard = topology_lock.write();
2018 let orphaned_indices: Vec<String> = self
2019 .inner
2020 .index_store
2021 .list_indices(name)
2022 .into_iter()
2023 .map(|index| index.name)
2024 .collect();
2025 for index_name in &orphaned_indices {
2026 self.inner.index_store.drop_index(index_name, name);
2027 }
2028
2029 store
2030 .drop_collection(name)
2031 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2032 self.inner.db.invalidate_vector_index(name);
2033 self.inner.db.clear_collection_default_ttl_ms(name);
2034 self.inner
2035 .db
2036 .remove_collection_contract(name)
2037 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2038 self.clear_table_planner_stats(name);
2039 self.invalidate_result_cache();
2040 if let Some(store) = self.inner.auth_store.read().clone() {
2041 store.invalidate_visible_collections_cache();
2042 }
2043 self.schema_vocabulary_apply(
2047 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
2048 collection: name.to_string(),
2049 },
2050 );
2051
2052 Ok(RuntimeQueryResult::ok_message(
2053 raw_query.to_string(),
2054 &format!("{label} '{name}' dropped"),
2055 "drop",
2056 ))
2057 }
2058}
2059
2060pub(crate) fn is_system_schema_name(name: &str) -> bool {
2061 name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_")
2062}
2063
2064fn entity_index_fields(data: &EntityData) -> Vec<(String, Value)> {
2065 match data {
2066 EntityData::Row(row) => {
2067 if let Some(ref named) = row.named {
2068 named
2069 .iter()
2070 .map(|(key, value)| (key.clone(), value.clone()))
2071 .collect()
2072 } else if let Some(ref schema) = row.schema {
2073 schema
2074 .iter()
2075 .zip(row.columns.iter())
2076 .map(|(key, value)| (key.clone(), value.clone()))
2077 .collect()
2078 } else {
2079 Vec::new()
2080 }
2081 }
2082 EntityData::Node(node) => node
2083 .properties
2084 .iter()
2085 .map(|(key, value)| (key.clone(), value.clone()))
2086 .collect(),
2087 _ => Vec::new(),
2088 }
2089}
2090
2091fn validate_ai_policy_modalities(policy: &crate::catalog::AiPolicy) -> RedDBResult<()> {
2097 use crate::runtime::ai::provider_capabilities::{Modality, Registry};
2098 let registry = Registry::new();
2099 let check = |provider: &str, model: &str, modality: Modality| -> RedDBResult<()> {
2100 registry
2101 .validate_policy_modality(provider, model, modality)
2102 .map_err(|err| RedDBError::Query(err.to_string()))
2103 };
2104 if let Some(embed) = &policy.embed {
2105 check(&embed.provider, &embed.model, Modality::Embed)?;
2106 }
2107 if let Some(moderate) = &policy.moderate {
2108 check(&moderate.provider, &moderate.model, Modality::Moderate)?;
2109 }
2110 if let Some(vision) = &policy.vision {
2111 check(&vision.provider, &vision.model, Modality::Vision)?;
2112 }
2113 Ok(())
2114}
2115
2116fn collection_contract_from_create_table(
2117 query: &CreateTableQuery,
2118) -> RedDBResult<crate::physical::CollectionContract> {
2119 reddb_engine::catalog::ddl::collection_contract_from_create_table(query, current_unix_ms())
2120 .map_err(RedDBError::Query)
2121}
2122
2123fn default_collection_contract_for_existing_table(
2124 name: &str,
2125) -> crate::physical::CollectionContract {
2126 let now = current_unix_ms();
2127 crate::physical::CollectionContract {
2128 name: name.to_string(),
2129 declared_model: crate::catalog::CollectionModel::Table,
2130 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2131 origin: crate::physical::ContractOrigin::Explicit,
2132 version: 0,
2133 created_at_unix_ms: now,
2134 updated_at_unix_ms: now,
2135 default_ttl_ms: None,
2136 vector_dimension: None,
2137 vector_metric: None,
2138 context_index_fields: Vec::new(),
2139 declared_columns: Vec::new(),
2140 table_def: Some(reddb_types::TableDef::new(name.to_string())),
2141 timestamps_enabled: false,
2142 context_index_enabled: false,
2143 metrics_raw_retention_ms: None,
2144 metrics_rollup_policies: Vec::new(),
2145 metrics_tenant_identity: None,
2146 metrics_namespace: None,
2147 append_only: false,
2148 subscriptions: Vec::new(),
2149 analytics_config: Vec::new(),
2150 session_key: None,
2151 session_gap_ms: None,
2152 retention_duration_ms: None,
2153 analytical_storage: None,
2154
2155 ai_policy: None,
2156 }
2157}
2158
2159fn keyed_collection_contract(
2160 name: &str,
2161 model: crate::catalog::CollectionModel,
2162 analytics_config: Vec<crate::catalog::AnalyticsViewDescriptor>,
2163) -> crate::physical::CollectionContract {
2164 let now = current_unix_ms();
2165 crate::physical::CollectionContract {
2166 name: name.to_string(),
2167 declared_model: model,
2168 schema_mode: crate::catalog::SchemaMode::Dynamic,
2169 origin: crate::physical::ContractOrigin::Explicit,
2170 version: 1,
2171 created_at_unix_ms: now,
2172 updated_at_unix_ms: now,
2173 default_ttl_ms: None,
2174 vector_dimension: None,
2175 vector_metric: None,
2176 context_index_fields: Vec::new(),
2177 declared_columns: Vec::new(),
2178 table_def: None,
2179 timestamps_enabled: false,
2180 context_index_enabled: false,
2181 metrics_raw_retention_ms: None,
2182 metrics_rollup_policies: Vec::new(),
2183 metrics_tenant_identity: None,
2184 metrics_namespace: None,
2185 append_only: false,
2186 subscriptions: Vec::new(),
2187 analytics_config,
2188 session_key: None,
2189 session_gap_ms: None,
2190 retention_duration_ms: None,
2191 analytical_storage: None,
2192
2193 ai_policy: None,
2194 }
2195}
2196
2197fn metrics_collection_contract(query: &CreateTableQuery) -> crate::physical::CollectionContract {
2198 let now = current_unix_ms();
2199 crate::physical::CollectionContract {
2200 name: query.name.clone(),
2201 declared_model: crate::catalog::CollectionModel::Metrics,
2202 schema_mode: crate::catalog::SchemaMode::SemiStructured,
2203 origin: crate::physical::ContractOrigin::Explicit,
2204 version: 1,
2205 created_at_unix_ms: now,
2206 updated_at_unix_ms: now,
2207 default_ttl_ms: query.default_ttl_ms,
2208 vector_dimension: None,
2209 vector_metric: None,
2210 context_index_fields: Vec::new(),
2211 declared_columns: Vec::new(),
2212 table_def: None,
2213 timestamps_enabled: false,
2214 context_index_enabled: false,
2215 metrics_raw_retention_ms: query.default_ttl_ms,
2216 metrics_rollup_policies: query.metrics_rollup_policies.clone(),
2217 metrics_tenant_identity: Some(
2218 query
2219 .tenant_by
2220 .clone()
2221 .unwrap_or_else(|| "current_tenant".to_string()),
2222 ),
2223 metrics_namespace: Some("default".to_string()),
2224 append_only: true,
2225 subscriptions: Vec::new(),
2226 analytics_config: Vec::new(),
2227 session_key: None,
2228 session_gap_ms: None,
2229 retention_duration_ms: None,
2230 analytical_storage: None,
2231
2232 ai_policy: None,
2233 }
2234}
2235
2236fn vector_collection_contract(query: &CreateVectorQuery) -> crate::physical::CollectionContract {
2237 let now = current_unix_ms();
2238 crate::physical::CollectionContract {
2239 name: query.name.clone(),
2240 declared_model: crate::catalog::CollectionModel::Vector,
2241 schema_mode: crate::catalog::SchemaMode::Dynamic,
2242 origin: crate::physical::ContractOrigin::Explicit,
2243 version: 1,
2244 created_at_unix_ms: now,
2245 updated_at_unix_ms: now,
2246 default_ttl_ms: None,
2247 vector_dimension: Some(query.dimension),
2248 vector_metric: Some(query.metric),
2249 context_index_fields: Vec::new(),
2250 declared_columns: Vec::new(),
2251 table_def: None,
2252 timestamps_enabled: false,
2253 context_index_enabled: false,
2254 metrics_raw_retention_ms: None,
2255 metrics_rollup_policies: Vec::new(),
2256 metrics_tenant_identity: None,
2257 metrics_namespace: None,
2258 append_only: false,
2259 subscriptions: Vec::new(),
2260 analytics_config: Vec::new(),
2261 session_key: None,
2262 session_gap_ms: None,
2263 retention_duration_ms: None,
2264 analytical_storage: None,
2265
2266 ai_policy: None,
2267 }
2268}
2269
2270fn apply_alter_operations_to_contract(
2271 contract: &mut crate::physical::CollectionContract,
2272 operations: &[AlterOperation],
2273) {
2274 if contract.table_def.is_none() {
2275 contract.table_def = Some(reddb_types::TableDef::new(contract.name.clone()));
2276 }
2277 for operation in operations {
2278 match operation {
2279 AlterOperation::AddColumn(column) => {
2280 if !contract
2281 .declared_columns
2282 .iter()
2283 .any(|existing| existing.name == column.name)
2284 {
2285 contract
2286 .declared_columns
2287 .push(declared_column_contract_from_ddl(column));
2288 }
2289 if let Some(table_def) = contract.table_def.as_mut() {
2290 if table_def.get_column(&column.name).is_none() {
2291 if let Ok(column_def) = column_def_from_ddl(column) {
2292 if column.primary_key {
2293 table_def.primary_key.push(column.name.clone());
2294 table_def.constraints.push(
2295 reddb_types::Constraint::new(
2296 format!("pk_{}", column.name),
2297 reddb_types::ConstraintType::PrimaryKey,
2298 )
2299 .on_columns(vec![column.name.clone()]),
2300 );
2301 }
2302 if column.unique {
2303 table_def.constraints.push(
2304 reddb_types::Constraint::new(
2305 format!("uniq_{}", column.name),
2306 reddb_types::ConstraintType::Unique,
2307 )
2308 .on_columns(vec![column.name.clone()]),
2309 );
2310 }
2311 if column.not_null {
2312 table_def.constraints.push(
2313 reddb_types::Constraint::new(
2314 format!("not_null_{}", column.name),
2315 reddb_types::ConstraintType::NotNull,
2316 )
2317 .on_columns(vec![column.name.clone()]),
2318 );
2319 }
2320 table_def.columns.push(column_def);
2321 }
2322 }
2323 }
2324 }
2325 AlterOperation::DropColumn(name) => {
2326 contract
2327 .declared_columns
2328 .retain(|column| column.name != *name);
2329 if let Some(table_def) = contract.table_def.as_mut() {
2330 if let Some(index) = table_def.column_index(name) {
2331 table_def.columns.remove(index);
2332 }
2333 table_def.primary_key.retain(|column| column != name);
2334 table_def.constraints.retain(|constraint| {
2335 !constraint.columns.iter().any(|column| column == name)
2336 });
2337 table_def
2338 .indexes
2339 .retain(|index| !index.columns.iter().any(|column| column == name));
2340 }
2341 }
2342 AlterOperation::RenameColumn { from, to } => {
2343 if contract
2344 .declared_columns
2345 .iter()
2346 .any(|column| column.name == *to)
2347 {
2348 continue;
2349 }
2350 if let Some(column) = contract
2351 .declared_columns
2352 .iter_mut()
2353 .find(|column| column.name == *from)
2354 {
2355 column.name = to.clone();
2356 }
2357 if let Some(table_def) = contract.table_def.as_mut() {
2358 if let Some(column) = table_def
2359 .columns
2360 .iter_mut()
2361 .find(|column| column.name == *from)
2362 {
2363 column.name = to.clone();
2364 }
2365 for primary_key in &mut table_def.primary_key {
2366 if *primary_key == *from {
2367 *primary_key = to.clone();
2368 }
2369 }
2370 for constraint in &mut table_def.constraints {
2371 for column in &mut constraint.columns {
2372 if *column == *from {
2373 *column = to.clone();
2374 }
2375 }
2376 if let Some(ref_columns) = constraint.ref_columns.as_mut() {
2377 for column in ref_columns {
2378 if *column == *from {
2379 *column = to.clone();
2380 }
2381 }
2382 }
2383 }
2384 for index in &mut table_def.indexes {
2385 for column in &mut index.columns {
2386 if *column == *from {
2387 *column = to.clone();
2388 }
2389 }
2390 }
2391 }
2392 }
2393 AlterOperation::AttachPartition { .. } | AlterOperation::DetachPartition { .. } => {}
2396 AlterOperation::EnableRowLevelSecurity | AlterOperation::DisableRowLevelSecurity => {}
2400 AlterOperation::EnableTenancy { .. } | AlterOperation::DisableTenancy => {}
2403 AlterOperation::SetAppendOnly(on) => {
2404 contract.append_only = *on;
2405 }
2406 AlterOperation::SetVersioned(_) => {}
2409 AlterOperation::EnableEvents(subscription) => {
2410 let mut subscription = subscription.clone();
2411 subscription.source = contract.name.clone();
2412 subscription.enabled = true;
2413 if let Some(existing) = contract
2414 .subscriptions
2415 .iter_mut()
2416 .find(|existing| existing.target_queue == subscription.target_queue)
2417 {
2418 *existing = subscription;
2419 } else {
2420 contract.subscriptions.push(subscription);
2421 }
2422 }
2423 AlterOperation::DisableEvents => {
2424 for subscription in &mut contract.subscriptions {
2425 subscription.enabled = false;
2426 }
2427 }
2428 AlterOperation::AddSubscription { name, descriptor } => {
2429 let mut sub = descriptor.clone();
2430 sub.name = name.clone();
2431 sub.source = contract.name.clone();
2432 sub.enabled = true;
2433 if let Some(existing) = contract.subscriptions.iter_mut().find(|s| s.name == *name)
2434 {
2435 *existing = sub;
2436 } else {
2437 contract.subscriptions.push(sub);
2438 }
2439 }
2440 AlterOperation::DropSubscription { name } => {
2441 contract.subscriptions.retain(|s| s.name != *name);
2442 }
2443 AlterOperation::AddSigner { .. } | AlterOperation::RevokeSigner { .. } => {}
2448 AlterOperation::SetRetention { duration_ms } => {
2449 contract.retention_duration_ms = Some(*duration_ms);
2450 }
2451 AlterOperation::UnsetRetention => {
2452 contract.retention_duration_ms = None;
2453 }
2454 AlterOperation::AddAnalytics(views) => {
2458 for view in views {
2459 if !contract
2463 .analytics_config
2464 .iter()
2465 .any(|existing| existing.output == view.output)
2466 {
2467 contract.analytics_config.push(view.clone());
2468 }
2469 }
2470 }
2471 AlterOperation::DropAnalytics(output) => {
2472 contract
2473 .analytics_config
2474 .retain(|view| view.output != *output);
2475 }
2476 }
2477 }
2478}
2479
2480pub(crate) fn retention_timestamp_column_exists(
2485 contract: &crate::physical::CollectionContract,
2486) -> bool {
2487 if contract.timestamps_enabled {
2488 return true;
2489 }
2490 if matches!(
2491 contract.declared_model,
2492 crate::catalog::CollectionModel::TimeSeries | crate::catalog::CollectionModel::Metrics
2493 ) {
2494 return true;
2498 }
2499 contract
2500 .declared_columns
2501 .iter()
2502 .any(|column| is_temporal_data_type(&column.data_type))
2503}
2504
2505fn is_temporal_data_type(data_type: &str) -> bool {
2506 let upper = data_type.to_ascii_uppercase();
2507 matches!(
2508 upper.as_str(),
2509 "TIMESTAMP" | "TIMESTAMPMS" | "TIMESTAMP_MS" | "DATETIME" | "DATE"
2510 )
2511}
2512
2513fn validate_event_subscriptions(
2514 runtime: &RedDBRuntime,
2515 source: &str,
2516 subscriptions: &[crate::catalog::SubscriptionDescriptor],
2517) -> RedDBResult<()> {
2518 for subscription in subscriptions
2519 .iter()
2520 .filter(|subscription| subscription.enabled)
2521 {
2522 if subscription.all_tenants && crate::runtime::impl_core::current_tenant().is_some() {
2523 return Err(RedDBError::Query(
2524 "cross-tenant subscription requires cluster-admin capability (events:cluster_subscribe)".to_string(),
2525 ));
2526 }
2527 validate_subscription_auth(runtime, source, subscription)?;
2528 if subscription.target_queue == source
2529 || subscription_would_create_cycle(
2530 &runtime.inner.db,
2531 source,
2532 &subscription.target_queue,
2533 )
2534 {
2535 return Err(RedDBError::Query(
2536 "subscription would create cycle".to_string(),
2537 ));
2538 }
2539 audit_subscription_redact_gap(runtime, source, subscription);
2540 }
2541 Ok(())
2542}
2543
2544fn validate_subscription_auth(
2545 runtime: &RedDBRuntime,
2546 source: &str,
2547 subscription: &crate::catalog::SubscriptionDescriptor,
2548) -> RedDBResult<()> {
2549 let auth_store = match runtime.inner.auth_store.read().clone() {
2550 Some(store) => store,
2551 None => return Ok(()),
2552 };
2553 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2554 Some(identity) => identity,
2555 None => return Ok(()),
2556 };
2557 let tenant = crate::runtime::impl_core::current_tenant();
2558 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2559
2560 if auth_store.iam_authorization_enabled() {
2561 let ctx = crate::auth::policies::EvalContext {
2562 principal_tenant: tenant.clone(),
2563 current_tenant: tenant.clone(),
2564 peer_ip: None,
2565 mfa_present: false,
2566 now_ms: crate::auth::now_ms(),
2567 principal_is_admin_role: role == crate::auth::Role::Admin,
2568 principal_is_platform_scoped: principal.tenant.is_none(),
2569 };
2570 let mut source_resource = crate::auth::policies::ResourceRef::new("table", source);
2571 if let Some(t) = tenant.as_deref() {
2572 source_resource = source_resource.with_tenant(t.to_string());
2573 }
2574 if !auth_store.check_policy_authz_with_role(
2575 &principal,
2576 "select",
2577 &source_resource,
2578 &ctx,
2579 role,
2580 ) {
2581 return Err(RedDBError::Query(format!(
2582 "permission denied: principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
2583 principal, source_resource.kind, source_resource.name
2584 )));
2585 }
2586
2587 let mut target_resource =
2588 crate::auth::policies::ResourceRef::new("queue", subscription.target_queue.clone());
2589 if let Some(t) = tenant.as_deref() {
2590 target_resource = target_resource.with_tenant(t.to_string());
2591 }
2592 if !auth_store.check_policy_authz_with_role(
2593 &principal,
2594 "write",
2595 &target_resource,
2596 &ctx,
2597 role,
2598 ) {
2599 return Err(RedDBError::Query(format!(
2600 "permission denied: principal=`{}` action=`write` resource=`{}:{}` denied by IAM policy",
2601 principal, target_resource.kind, target_resource.name
2602 )));
2603 }
2604 return Ok(());
2605 }
2606
2607 let ctx = crate::auth::privileges::AuthzContext {
2608 principal: &username,
2609 effective_role: role,
2610 tenant: tenant.as_deref(),
2611 };
2612 auth_store
2613 .check_grant(
2614 &ctx,
2615 crate::auth::privileges::Action::Select,
2616 &crate::auth::privileges::Resource::table_from_name(source),
2617 )
2618 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2619 auth_store
2620 .check_grant(
2621 &ctx,
2622 crate::auth::privileges::Action::Insert,
2623 &crate::auth::privileges::Resource::table_from_name(&subscription.target_queue),
2624 )
2625 .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))?;
2626 Ok(())
2627}
2628
2629fn audit_subscription_redact_gap(
2630 runtime: &RedDBRuntime,
2631 source: &str,
2632 subscription: &crate::catalog::SubscriptionDescriptor,
2633) {
2634 let auth_store = match runtime.inner.auth_store.read().clone() {
2635 Some(store) if store.iam_authorization_enabled() => store,
2636 _ => return,
2637 };
2638 let (username, role) = match crate::runtime::impl_core::current_auth_identity() {
2639 Some(identity) => identity,
2640 None => return,
2641 };
2642 let tenant = crate::runtime::impl_core::current_tenant();
2643 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
2644 let missing = subscription_redact_gap_columns(&auth_store, &principal, source, subscription);
2645 if missing.is_empty() {
2646 return;
2647 }
2648
2649 let columns = missing.into_iter().collect::<Vec<_>>().join(", ");
2650 tracing::warn!(
2651 target: "reddb::operator",
2652 "subscription_redact_gap: source={} target_queue={} columns=[{}]",
2653 source,
2654 subscription.target_queue,
2655 columns
2656 );
2657 let mut event = AuditEvent::builder("subscription_redact_gap")
2658 .principal(username)
2659 .source(AuditAuthSource::System)
2660 .resource(format!(
2661 "subscription:{}->{}",
2662 source, subscription.target_queue
2663 ))
2664 .outcome(Outcome::Success)
2665 .field(AuditFieldEscaper::field("source", source))
2666 .field(AuditFieldEscaper::field(
2667 "target_queue",
2668 subscription.target_queue.clone(),
2669 ))
2670 .field(AuditFieldEscaper::field(
2671 "subscription",
2672 subscription.name.clone(),
2673 ))
2674 .field(AuditFieldEscaper::field("columns", columns))
2675 .field(AuditFieldEscaper::field("role", role.as_str()));
2676 if let Some(t) = tenant {
2677 event = event.tenant(t);
2678 }
2679 runtime.inner.audit_log.record_event(event.build());
2680}
2681
2682fn subscription_redact_gap_columns(
2683 auth_store: &crate::auth::store::AuthStore,
2684 principal: &crate::auth::UserId,
2685 source: &str,
2686 subscription: &crate::catalog::SubscriptionDescriptor,
2687) -> BTreeSet<String> {
2688 let redacted: HashSet<String> = subscription
2689 .redact_fields
2690 .iter()
2691 .map(|field| field.to_ascii_lowercase())
2692 .collect();
2693 auth_store
2694 .effective_policies(principal)
2695 .iter()
2696 .flat_map(|policy| policy.statements.iter())
2697 .filter(|statement| statement.effect == crate::auth::policies::Effect::Deny)
2698 .filter(|statement| statement.actions.iter().any(action_pattern_matches_select))
2699 .flat_map(|statement| statement.resources.iter())
2700 .filter_map(|resource| denied_column_for_source(resource, source))
2701 .filter(|column| !redact_covers_column(&redacted, source, column))
2702 .collect()
2703}
2704
2705fn action_pattern_matches_select(pattern: &crate::auth::policies::ActionPattern) -> bool {
2706 match pattern {
2707 crate::auth::policies::ActionPattern::Wildcard => true,
2708 crate::auth::policies::ActionPattern::Exact(action) => action == "select",
2709 crate::auth::policies::ActionPattern::Prefix(prefix) => {
2710 "select".len() > prefix.len() + 1
2711 && "select".starts_with(prefix)
2712 && "select".as_bytes()[prefix.len()] == b':'
2713 }
2714 }
2715}
2716
2717fn denied_column_for_source(
2718 resource: &crate::auth::policies::ResourcePattern,
2719 source: &str,
2720) -> Option<String> {
2721 let crate::auth::policies::ResourcePattern::Exact { kind, name } = resource else {
2722 return None;
2723 };
2724 if kind != "column" {
2725 return None;
2726 }
2727 let column = crate::auth::ColumnRef::parse_resource_name(name).ok()?;
2728 (column.table_resource_name() == source).then_some(column.column)
2729}
2730
2731fn redact_covers_column(redacted: &HashSet<String>, source: &str, column: &str) -> bool {
2732 let column = column.to_ascii_lowercase();
2733 let qualified = format!("{}.{}", source.to_ascii_lowercase(), column);
2734 redacted.contains("*") || redacted.contains(&column) || redacted.contains(&qualified)
2735}
2736
2737fn subscription_would_create_cycle(
2738 db: &crate::storage::unified::devx::RedDB,
2739 source: &str,
2740 target: &str,
2741) -> bool {
2742 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
2743 for contract in db.collection_contracts() {
2744 for subscription in contract
2745 .subscriptions
2746 .into_iter()
2747 .filter(|subscription| subscription.enabled)
2748 {
2749 graph
2750 .entry(subscription.source)
2751 .or_default()
2752 .push(subscription.target_queue);
2753 }
2754 }
2755 graph
2756 .entry(source.to_string())
2757 .or_default()
2758 .push(target.to_string());
2759
2760 let mut stack = vec![target.to_string()];
2761 let mut seen = HashSet::new();
2762 while let Some(node) = stack.pop() {
2763 if node == source {
2764 return true;
2765 }
2766 if !seen.insert(node.clone()) {
2767 continue;
2768 }
2769 if let Some(next) = graph.get(&node) {
2770 stack.extend(next.iter().cloned());
2771 }
2772 }
2773 false
2774}
2775
2776pub(crate) fn ensure_event_target_queue_pub(
2777 runtime: &RedDBRuntime,
2778 queue: &str,
2779) -> RedDBResult<()> {
2780 ensure_event_target_queue(runtime, queue)
2781}
2782
2783fn ensure_event_target_queue(runtime: &RedDBRuntime, queue: &str) -> RedDBResult<()> {
2784 let store = runtime.inner.db.store();
2785 if store.get_collection(queue).is_some() {
2786 return Ok(());
2787 }
2788 store
2789 .create_collection(queue)
2790 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2791 runtime
2792 .inner
2793 .db
2794 .save_collection_contract(event_queue_collection_contract(queue))
2795 .map_err(|err| RedDBError::Internal(err.to_string()))?;
2796 store.set_config_tree(
2797 &format!("queue.{queue}.mode"),
2798 &crate::serde_json::Value::String("fanout".to_string()),
2799 );
2800 Ok(())
2801}
2802
2803fn event_queue_collection_contract(queue: &str) -> crate::physical::CollectionContract {
2804 let now = current_unix_ms();
2805 crate::physical::CollectionContract {
2806 name: queue.to_string(),
2807 declared_model: crate::catalog::CollectionModel::Queue,
2808 schema_mode: crate::catalog::SchemaMode::Dynamic,
2809 origin: crate::physical::ContractOrigin::Implicit,
2810 version: 1,
2811 created_at_unix_ms: now,
2812 updated_at_unix_ms: now,
2813 default_ttl_ms: None,
2814 vector_dimension: None,
2815 vector_metric: None,
2816 context_index_fields: Vec::new(),
2817 declared_columns: Vec::new(),
2818 table_def: None,
2819 timestamps_enabled: false,
2820 context_index_enabled: false,
2821 metrics_raw_retention_ms: None,
2822 metrics_rollup_policies: Vec::new(),
2823 metrics_tenant_identity: None,
2824 metrics_namespace: None,
2825 append_only: true,
2826 subscriptions: Vec::new(),
2827 analytics_config: Vec::new(),
2828 session_key: None,
2829 session_gap_ms: None,
2830 retention_duration_ms: None,
2831 analytical_storage: None,
2832
2833 ai_policy: None,
2834 }
2835}
2836
2837fn column_def_from_ddl(column: &CreateColumnDef) -> RedDBResult<reddb_types::ColumnDef> {
2838 reddb_engine::catalog::ddl::column_def_from_ddl(column).map_err(RedDBError::Query)
2839}
2840
2841fn current_unix_ms() -> u128 {
2842 std::time::SystemTime::now()
2843 .duration_since(std::time::UNIX_EPOCH)
2844 .unwrap_or_default()
2845 .as_millis()
2846}
2847
2848#[cfg(test)]
2849mod tests {
2850 use crate::auth::policies::{ActionPattern, Effect, Policy, ResourcePattern, Statement};
2851 use crate::auth::store::{AuthStore, PrincipalRef};
2852 use crate::auth::UserId;
2853 use crate::auth::{AuthConfig, Role};
2854 use crate::runtime::impl_core::{clear_current_auth_identity, set_current_auth_identity};
2855 use crate::{RedDBOptions, RedDBRuntime};
2856 use reddb_types::Value;
2857 use std::sync::Arc;
2858
2859 fn make_allow_policy(id: &str, action: &str, collection: &str) -> Policy {
2860 Policy {
2861 id: id.to_string(),
2862 version: 1,
2863 tenant: None,
2864 created_at: 0,
2865 updated_at: 0,
2866 statements: vec![Statement {
2867 sid: None,
2868 effect: Effect::Allow,
2869 actions: vec![ActionPattern::Exact(action.to_string())],
2870 resources: vec![ResourcePattern::Exact {
2871 kind: "collection".to_string(),
2872 name: collection.to_string(),
2873 }],
2874 condition: None,
2875 }],
2876 }
2877 }
2878
2879 fn wire_auth_store(rt: &RedDBRuntime) -> Arc<AuthStore> {
2880 let store = Arc::new(AuthStore::new(AuthConfig::default()));
2881 *rt.inner.auth_store.write() = Some(store.clone());
2882 store
2883 }
2884
2885 #[test]
2886 fn drop_denied_without_iam_policy() {
2887 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2888 rt.execute_query("CREATE TABLE foo (id INT)").unwrap();
2889 let store = wire_auth_store(&rt);
2890 let select_only = Policy {
2892 id: "select-only".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("select".to_string())],
2901 resources: vec![ResourcePattern::Wildcard],
2902 condition: None,
2903 }],
2904 };
2905 store.put_policy_internal(select_only).unwrap();
2906 let alice = UserId::from_parts(None, "alice");
2907 store
2908 .attach_policy(PrincipalRef::User(alice), "select-only")
2909 .unwrap();
2910 set_current_auth_identity("alice".to_string(), Role::Write);
2911 let err = rt.execute_query("DROP TABLE foo").unwrap_err();
2912 clear_current_auth_identity();
2913 assert!(
2914 format!("{err}").contains("denied by IAM policy"),
2915 "got: {err}"
2916 );
2917 }
2918
2919 #[test]
2920 fn drop_allowed_with_explicit_iam_policy() {
2921 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2922 rt.execute_query("CREATE TABLE bar (id INT)").unwrap();
2923 let store = wire_auth_store(&rt);
2924 let policy = make_allow_policy("allow-drop-bar", "drop", "bar");
2925 store.put_policy_internal(policy).unwrap();
2926 let bob = UserId::from_parts(None, "bob");
2927 store
2928 .attach_policy(PrincipalRef::User(bob), "allow-drop-bar")
2929 .unwrap();
2930 set_current_auth_identity("bob".to_string(), Role::Write);
2931 rt.execute_query("DROP TABLE bar").unwrap();
2932 clear_current_auth_identity();
2933 }
2934
2935 #[test]
2936 fn drop_allowed_with_wildcard_iam_policy() {
2937 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2938 rt.execute_query("CREATE TABLE baz (id INT)").unwrap();
2939 let store = wire_auth_store(&rt);
2940 let policy = Policy {
2941 id: "allow-drop-all".to_string(),
2942 version: 1,
2943 tenant: None,
2944 created_at: 0,
2945 updated_at: 0,
2946 statements: vec![Statement {
2947 sid: None,
2948 effect: Effect::Allow,
2949 actions: vec![ActionPattern::Exact("drop".to_string())],
2950 resources: vec![ResourcePattern::Wildcard],
2951 condition: None,
2952 }],
2953 };
2954 store.put_policy_internal(policy).unwrap();
2955 let carl = UserId::from_parts(None, "carl");
2956 store
2957 .attach_policy(PrincipalRef::User(carl), "allow-drop-all")
2958 .unwrap();
2959 set_current_auth_identity("carl".to_string(), Role::Write);
2960 rt.execute_query("DROP TABLE baz").unwrap();
2961 clear_current_auth_identity();
2962 }
2963
2964 #[test]
2965 fn truncate_denied_without_iam_policy() {
2966 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2967 rt.execute_query("CREATE TABLE qux (id INT)").unwrap();
2968 let store = wire_auth_store(&rt);
2969 store
2976 .set_enforcement_mode(crate::auth::enforcement_mode::PolicyEnforcementMode::PolicyOnly);
2977 let select_only = Policy {
2979 id: "select-only-2".to_string(),
2980 version: 1,
2981 tenant: None,
2982 created_at: 0,
2983 updated_at: 0,
2984 statements: vec![Statement {
2985 sid: None,
2986 effect: Effect::Allow,
2987 actions: vec![ActionPattern::Exact("select".to_string())],
2988 resources: vec![ResourcePattern::Wildcard],
2989 condition: None,
2990 }],
2991 };
2992 store.put_policy_internal(select_only).unwrap();
2993 let dana = UserId::from_parts(None, "dana");
2994 store
2995 .attach_policy(PrincipalRef::User(dana), "select-only-2")
2996 .unwrap();
2997 set_current_auth_identity("dana".to_string(), Role::Write);
2998 let err = rt.execute_query("TRUNCATE TABLE qux").unwrap_err();
2999 clear_current_auth_identity();
3000 assert!(
3001 format!("{err}").contains("denied by IAM policy"),
3002 "got: {err}"
3003 );
3004 }
3005
3006 #[test]
3007 fn truncate_table_clears_rows_and_preserves_schema_and_indexes() {
3008 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3009 rt.execute_query("CREATE TABLE users (id INT, name TEXT)")
3010 .unwrap();
3011 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'ana'), (2, 'bob')")
3012 .unwrap();
3013 rt.execute_query("CREATE INDEX idx_users_id ON users (id) USING HASH")
3014 .unwrap();
3015
3016 let truncated = rt.execute_query("TRUNCATE TABLE users").unwrap();
3017 assert_eq!(truncated.statement_type, "truncate");
3018 assert_eq!(truncated.affected_rows, 0);
3019
3020 let empty = rt.execute_query("SELECT id FROM users").unwrap();
3021 assert!(empty.result.records.is_empty());
3022
3023 rt.execute_query("INSERT INTO users (id, name) VALUES (3, 'cy')")
3024 .unwrap();
3025 let selected = rt
3026 .execute_query("SELECT name FROM users WHERE id = 3")
3027 .unwrap();
3028 let name = selected.result.records[0].get("name").unwrap();
3029 assert_eq!(name, &Value::text("cy"));
3030 assert!(rt.db().collection_contract("users").is_some());
3031 assert!(rt
3032 .inner
3033 .index_store
3034 .list_indices("users")
3035 .iter()
3036 .any(|index| index.name == "idx_users_id"));
3037 }
3038
3039 #[test]
3040 fn truncate_collection_is_polymorphic_and_typed_mismatch_fails() {
3041 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3042 rt.execute_query("CREATE QUEUE tasks").unwrap();
3043 rt.execute_query("QUEUE PUSH tasks {'job':'a'}").unwrap();
3044
3045 let err = rt.execute_query("TRUNCATE TABLE tasks").unwrap_err();
3046 assert!(format!("{err}").contains("model mismatch: expected table, got queue"));
3047
3048 rt.execute_query("TRUNCATE COLLECTION tasks").unwrap();
3049 let len = rt.execute_query("QUEUE LEN tasks").unwrap();
3050 assert_eq!(
3051 len.result.records[0].get("len"),
3052 Some(&Value::UnsignedInteger(0))
3053 );
3054 }
3055
3056 #[test]
3057 fn truncate_system_schema_is_read_only() {
3058 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3059 let err = rt
3060 .execute_query("TRUNCATE COLLECTION red.collections")
3061 .unwrap_err();
3062 assert!(format!("{err}").contains("system schema is read-only"));
3063 }
3064
3065 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3068 let result = rt
3069 .execute_query(&format!("QUEUE PEEK {queue} 100"))
3070 .expect("peek queue");
3071 result
3072 .result
3073 .records
3074 .iter()
3075 .map(
3076 |record| match record.get("payload").expect("payload column") {
3077 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3078 other => panic!("expected JSON queue payload, got {other:?}"),
3079 },
3080 )
3081 .collect()
3082 }
3083
3084 #[test]
3087 fn truncate_event_enabled_table_emits_single_truncate_event() {
3088 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3089 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3090 .unwrap();
3091 rt.execute_query(
3092 "INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')",
3093 )
3094 .unwrap();
3095
3096 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3098
3099 rt.execute_query("TRUNCATE TABLE users").unwrap();
3100
3101 let events = queue_payloads(&rt, "users_events");
3102 assert_eq!(
3104 events.len(),
3105 1,
3106 "expected 1 truncate event, got {}",
3107 events.len()
3108 );
3109 let ev = events[0].as_object().expect("event is object");
3110 assert_eq!(
3111 ev.get("op").and_then(crate::json::Value::as_str),
3112 Some("truncate")
3113 );
3114 assert_eq!(
3115 ev.get("collection").and_then(crate::json::Value::as_str),
3116 Some("users")
3117 );
3118 assert_eq!(
3119 ev.get("entities_count")
3120 .and_then(crate::json::Value::as_u64),
3121 Some(3)
3122 );
3123 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3124 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3125 assert!(ev
3126 .get("event_id")
3127 .and_then(crate::json::Value::as_str)
3128 .is_some_and(|s| !s.is_empty()));
3129 }
3130
3131 #[test]
3133 fn truncate_no_events_collection_emits_nothing() {
3134 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3135 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3136 .unwrap();
3137 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a'), (2, 'b')")
3138 .unwrap();
3139 rt.execute_query("TRUNCATE TABLE plain").unwrap();
3141 let rows = rt.execute_query("SELECT id FROM plain").unwrap();
3143 assert!(rows.result.records.is_empty());
3144 }
3145
3146 #[test]
3150 fn drop_event_enabled_table_emits_single_collection_dropped_event() {
3151 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3152 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3153 .unwrap();
3154 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'alice'), (2, 'bob')")
3155 .unwrap();
3156
3157 rt.execute_query("QUEUE POP users_events COUNT 10").unwrap();
3159
3160 rt.execute_query("DROP TABLE users").unwrap();
3161
3162 let events = queue_payloads(&rt, "users_events");
3164 assert_eq!(
3165 events.len(),
3166 1,
3167 "expected 1 collection_dropped event, got {}",
3168 events.len()
3169 );
3170 let ev = events[0].as_object().expect("event is object");
3171 assert_eq!(
3172 ev.get("op").and_then(crate::json::Value::as_str),
3173 Some("collection_dropped")
3174 );
3175 assert_eq!(
3176 ev.get("collection").and_then(crate::json::Value::as_str),
3177 Some("users")
3178 );
3179 assert_eq!(
3180 ev.get("final_entities_count")
3181 .and_then(crate::json::Value::as_u64),
3182 Some(2)
3183 );
3184 assert!(ev.get("ts").and_then(crate::json::Value::as_u64).is_some());
3185 assert!(ev.get("lsn").and_then(crate::json::Value::as_u64).is_some());
3186 assert!(ev
3187 .get("event_id")
3188 .and_then(crate::json::Value::as_str)
3189 .is_some_and(|s| !s.is_empty()));
3190
3191 let err = rt.execute_query("SELECT id FROM users").unwrap_err();
3193 assert!(
3194 format!("{err}").contains("users"),
3195 "expected not-found error"
3196 );
3197 }
3198
3199 #[test]
3202 fn drop_no_events_collection_emits_nothing() {
3203 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3204 rt.execute_query("CREATE TABLE plain (id INT, val TEXT)")
3205 .unwrap();
3206 rt.execute_query("INSERT INTO plain (id, val) VALUES (1, 'a')")
3207 .unwrap();
3208 rt.execute_query("DROP TABLE plain").unwrap();
3209 let err = rt.execute_query("SELECT id FROM plain").unwrap_err();
3211 assert!(format!("{err}").contains("plain"));
3212 }
3213
3214 #[test]
3218 fn ops_filter_insert_only_ignores_update_and_delete() {
3219 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3220 rt.execute_query(
3221 "CREATE TABLE items (id INT, val TEXT) WITH EVENTS (INSERT) TO items_events",
3222 )
3223 .unwrap();
3224 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 'a')")
3225 .unwrap();
3226 rt.execute_query("UPDATE items SET val = 'b' WHERE id = 1")
3227 .unwrap();
3228 rt.execute_query("DELETE FROM items WHERE id = 1").unwrap();
3229
3230 let events = queue_payloads(&rt, "items_events");
3231 assert_eq!(
3233 events.len(),
3234 1,
3235 "expected 1 insert event, got {}",
3236 events.len()
3237 );
3238 assert_eq!(
3239 events[0]
3240 .as_object()
3241 .unwrap()
3242 .get("op")
3243 .and_then(crate::json::Value::as_str),
3244 Some("insert")
3245 );
3246 }
3247
3248 #[test]
3250 fn where_filter_skips_rows_that_do_not_match() {
3251 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3252 rt.execute_query(
3253 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS WHERE status = 'active' TO users_events",
3254 )
3255 .unwrap();
3256
3257 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active')")
3259 .unwrap();
3260 rt.execute_query("INSERT INTO users (id, status) VALUES (2, 'inactive')")
3262 .unwrap();
3263
3264 let events = queue_payloads(&rt, "users_events");
3265 assert_eq!(
3266 events.len(),
3267 1,
3268 "expected 1 event (only active), got {}",
3269 events.len()
3270 );
3271 let ev = events[0].as_object().unwrap();
3272 assert_eq!(
3273 ev.get("op").and_then(crate::json::Value::as_str),
3274 Some("insert")
3275 );
3276 let after = ev.get("after").unwrap().as_object().unwrap();
3277 assert_eq!(
3278 after.get("status").and_then(crate::json::Value::as_str),
3279 Some("active")
3280 );
3281 }
3282
3283 #[test]
3285 fn ops_filter_and_where_filter_combined() {
3286 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3287 rt.execute_query(
3288 "CREATE TABLE items (id INT, status TEXT) WITH EVENTS (INSERT, UPDATE) WHERE status = 'active' TO items_events",
3289 )
3290 .unwrap();
3291
3292 rt.execute_query("INSERT INTO items (id, status) VALUES (1, 'active')")
3294 .unwrap();
3295 rt.execute_query("INSERT INTO items (id, status) VALUES (2, 'inactive')")
3297 .unwrap();
3298 rt.execute_query("UPDATE items SET status = 'inactive' WHERE id = 1")
3300 .unwrap();
3301 rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3303
3304 let events = queue_payloads(&rt, "items_events");
3305 assert_eq!(
3307 events.len(),
3308 1,
3309 "expected 1 event, got {}: {events:?}",
3310 events.len()
3311 );
3312 assert_eq!(
3313 events[0]
3314 .as_object()
3315 .unwrap()
3316 .get("op")
3317 .and_then(crate::json::Value::as_str),
3318 Some("insert")
3319 );
3320 }
3321
3322 #[test]
3324 fn where_filter_on_delete_checks_before_state() {
3325 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3326 rt.execute_query(
3327 "CREATE TABLE users (id INT, status TEXT) WITH EVENTS (DELETE) WHERE status = 'active' TO users_events",
3328 )
3329 .unwrap();
3330
3331 rt.execute_query("INSERT INTO users (id, status) VALUES (1, 'active'), (2, 'inactive')")
3332 .unwrap();
3333
3334 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3336 rt.execute_query("DELETE FROM users WHERE id = 2").unwrap();
3338
3339 let events = queue_payloads(&rt, "users_events");
3340 assert_eq!(
3341 events.len(),
3342 1,
3343 "expected 1 delete event, got {}",
3344 events.len()
3345 );
3346 let ev = events[0].as_object().unwrap();
3347 assert_eq!(
3348 ev.get("op").and_then(crate::json::Value::as_str),
3349 Some("delete")
3350 );
3351 }
3352
3353 #[test]
3357 fn alter_add_column_on_event_enabled_table_succeeds() {
3358 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3359 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO users_events")
3360 .unwrap();
3361 rt.execute_query("ALTER TABLE users ADD COLUMN phone TEXT")
3363 .unwrap();
3364 let contract = rt.db().collection_contract("users").unwrap();
3366 assert!(
3367 contract.declared_columns.iter().any(|c| c.name == "phone"),
3368 "phone column should be in contract"
3369 );
3370 assert!(
3372 contract.subscriptions.iter().any(|s| s.enabled),
3373 "subscription should remain enabled"
3374 );
3375 }
3376
3377 #[test]
3380 fn alter_drop_column_and_rls_on_event_enabled_table_succeeds() {
3381 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3382 rt.execute_query(
3383 "CREATE TABLE items (id INT, secret TEXT, status TEXT) WITH EVENTS TO items_events",
3384 )
3385 .unwrap();
3386 rt.execute_query("ALTER TABLE items DROP COLUMN secret")
3388 .unwrap();
3389 let contract = rt.db().collection_contract("items").unwrap();
3390 assert!(
3391 !contract.declared_columns.iter().any(|c| c.name == "secret"),
3392 "secret column should be removed"
3393 );
3394 rt.execute_query("ALTER TABLE items ENABLE ROW LEVEL SECURITY")
3396 .unwrap();
3397 assert!(
3399 contract.subscriptions.iter().any(|s| s.enabled),
3400 "subscription should remain enabled"
3401 );
3402 }
3403
3404 #[test]
3410 fn create_vector_marks_collection_as_turbo_baseline() {
3411 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3412 rt.execute_query("CREATE VECTOR embeddings DIM 4").unwrap();
3413 let store = rt.db().store();
3414 assert!(
3415 crate::runtime::vector_turbo_kind::is_turbo(&store, "embeddings"),
3416 "new vector collections must be turbo-marked baseline"
3417 );
3418 assert!(
3419 rt.db().turbo_state("embeddings").is_some(),
3420 "turbo_state must materialise after CREATE VECTOR"
3421 );
3422 }
3423
3424 #[test]
3428 fn create_table_does_not_mark_turbo() {
3429 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3430 rt.execute_query("CREATE TABLE plain (id INT)").unwrap();
3431 let store = rt.db().store();
3432 assert!(
3433 !crate::runtime::vector_turbo_kind::is_turbo(&store, "plain"),
3434 "non-vector collections must not gain the turbo marker"
3435 );
3436 assert!(rt.db().turbo_state("plain").is_none());
3437 }
3438
3439 #[test]
3442 fn create_collection_kind_vector_turbo_still_marked() {
3443 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3444 rt.execute_query("CREATE COLLECTION turbo_v KIND vector.turbo DIM 4")
3445 .unwrap();
3446 let store = rt.db().store();
3447 assert!(crate::runtime::vector_turbo_kind::is_turbo(
3448 &store, "turbo_v"
3449 ));
3450 assert!(rt.db().turbo_state("turbo_v").is_some());
3451 }
3452
3453 #[test]
3456 fn create_table_persists_and_introspects_ai_policy() {
3457 use crate::catalog::{ModerateDegradedMode, ModerateRejectAction};
3458 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3459 rt.execute_query(
3460 "CREATE TABLE posts (id INT, title TEXT, body TEXT, photo TEXT) WITH ( \
3461 EMBED (fields = ('title', 'body'), provider = 'openai', model = 'text-embedding-3-small'), \
3462 MODERATE (fields = ('body'), provider = 'openai', model = 'omni-moderation-latest', sync = true, degraded = closed, on_reject = flag), \
3463 VISION (image_field = 'photo', outputs = ('caption'), provider = 'openai', model = 'gpt-4o') \
3464 )",
3465 )
3466 .expect("create table with ai policy");
3467
3468 let contracts = rt.db().collection_contracts();
3469 let contract = contracts
3470 .iter()
3471 .find(|c| c.name == "posts")
3472 .expect("contract persisted");
3473 let policy = contract.ai_policy.as_ref().expect("ai policy persisted");
3474
3475 let embed = policy.embed.as_ref().expect("embed");
3476 assert_eq!(embed.fields, vec!["title".to_string(), "body".to_string()]);
3477 assert_eq!(embed.model, "text-embedding-3-small");
3478
3479 let moderate = policy.moderate.as_ref().expect("moderate");
3480 assert!(moderate.sync_gate);
3481 assert_eq!(moderate.degraded_mode, ModerateDegradedMode::Closed);
3482 assert_eq!(moderate.reject_action, ModerateRejectAction::Flag);
3483
3484 let vision = policy.vision.as_ref().expect("vision");
3485 assert_eq!(vision.image_field, "photo");
3486 assert_eq!(vision.model, "gpt-4o");
3487 }
3488
3489 #[test]
3493 fn create_table_rejects_ai_policy_incapable_provider() {
3494 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3495 let err = rt
3496 .execute_query(
3497 "CREATE TABLE bad (id INT, body TEXT) WITH ( \
3498 EMBED (fields = ('body'), provider = 'anthropic', model = 'claude-3-5-sonnet') \
3499 )",
3500 )
3501 .unwrap_err();
3502 assert!(
3503 err.to_string()
3504 .contains("cannot serve the 'embed' modality"),
3505 "{err}"
3506 );
3507 assert!(
3508 rt.db().store().get_collection("bad").is_none(),
3509 "rejected DDL must not create the collection"
3510 );
3511 }
3512}