1use crate::application::entity::{
10 metadata_from_json, AppliedEntityMutation, CreateDocumentInput, CreateEdgeInput,
11 CreateEntityOutput, CreateKvInput, CreateNodeInput, CreateRowInput, CreateRowsBatchInput,
12 CreateVectorInput, DeleteEntityInput, PatchEntityOperation, PatchEntityOperationType,
13 RowUpdateColumnRule, RowUpdateContractPlan,
14};
15use crate::application::ports::{
16 build_row_update_contract_plan, entity_row_fields_snapshot,
17 normalize_row_update_assignment_with_plan, normalize_row_update_value_for_rule,
18 RuntimeEntityPort,
19};
20use crate::application::ttl_payload::has_internal_ttl_metadata;
21use crate::runtime::mvcc::current_connection_id;
22use crate::storage::query::unified::{
23 sys_key_collection, sys_key_created_at, sys_key_kind, sys_key_rid, sys_key_tenant,
24 sys_key_updated_at, UnifiedRecord, UnifiedResult,
25};
26use crate::storage::unified::MetadataValue;
27use crate::storage::Metadata;
28use reddb_rql::ast::{
29 BinOp, CompareOp, Expr, FieldRef, Filter, OnConflictAction, ReturningItem, UpdateQuery,
30 UpdateTarget,
31};
32use reddb_rql::sql_lowering::{
33 effective_delete_filter, effective_insert_rows, effective_update_filter, fold_expr_to_value,
34};
35use std::collections::HashMap;
36use std::sync::Arc;
37
38use super::*;
39use super::impl_dml_support::*;
43use super::impl_dml_claim::*;
47use super::impl_dml_returning::*;
48use super::impl_dml_update_analysis::*;
49use super::impl_dml_update_target::*;
50pub(super) use super::impl_dml_ttl::{canonicalize_sql_ttl_metadata, resolve_sql_ttl_metadata_key};
56
57const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
58pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
59pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
60
61#[derive(Clone)]
62pub(super) struct CompiledUpdateAssignment {
63 column: String,
64 expr: Expr,
65 compound_op: Option<BinOp>,
66 metadata_key: Option<&'static str>,
67 row_rule: Option<RowUpdateColumnRule>,
68}
69
70pub(super) struct CompiledUpdatePlan {
71 pub(super) static_field_assignments: Vec<(String, Value)>,
72 pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
73 dynamic_assignments: Vec<CompiledUpdateAssignment>,
74 row_contract_plan: Option<RowUpdateContractPlan>,
75 row_modified_columns: Vec<String>,
76 row_touches_unique_columns: bool,
77}
78
79#[derive(Default)]
80pub(super) struct MaterializedUpdateAssignments {
81 pub(super) dynamic_field_assignments: Vec<(String, Value)>,
82 pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
83}
84
85fn resolve_excluded_assignment(
86 expr: &Expr,
87 excluded_fields: &[(String, Value)],
88) -> RedDBResult<Expr> {
89 match expr {
90 Expr::Column {
91 field: FieldRef::TableColumn { table, column },
92 span,
93 } if table.eq_ignore_ascii_case("excluded") => {
94 let value = excluded_fields
95 .iter()
96 .find(|(name, _)| name.eq_ignore_ascii_case(column))
97 .map(|(_, value)| value.clone())
98 .ok_or_else(|| {
99 RedDBError::Query(format!(
100 "ON CONFLICT EXCLUDED references unknown insert column '{column}'"
101 ))
102 })?;
103 Ok(Expr::Literal { value, span: *span })
104 }
105 Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
106 op: *op,
107 lhs: Box::new(resolve_excluded_assignment(lhs, excluded_fields)?),
108 rhs: Box::new(resolve_excluded_assignment(rhs, excluded_fields)?),
109 span: *span,
110 }),
111 _ => Ok(expr.clone()),
112 }
113}
114
115fn conflict_columns_match(index_columns: &[String], target_columns: &[String]) -> bool {
116 index_columns.len() == target_columns.len()
117 && index_columns.iter().all(|index_column| {
118 target_columns
119 .iter()
120 .any(|target_column| target_column.eq_ignore_ascii_case(index_column))
121 })
122}
123
124fn conflict_index_key(fields: &[(String, Value)], columns: &[String]) -> Option<Vec<Value>> {
125 columns
126 .iter()
127 .map(|column| {
128 super::index_store::index_field_value(fields, column)
129 .filter(|value| !matches!(value.as_ref(), Value::Null))
130 .map(|value| value.into_owned())
131 })
132 .collect()
133}
134
135fn document_on_conflict_fields(
136 stored_fields: &[(String, Value)],
137 body: &crate::json::Value,
138) -> RedDBResult<Vec<(String, Value)>> {
139 let mut fields = stored_fields.to_vec();
140 if let crate::json::Value::Object(object) = body {
141 for (name, value) in object {
142 fields.push((
143 name.clone(),
144 crate::application::entity::json_to_storage_value(value)?,
145 ));
146 }
147 }
148 Ok(fields)
149}
150
151impl RedDBRuntime {
152 fn conflict_unique_indexes(
153 &self,
154 collection: &str,
155 target: Option<&[String]>,
156 ) -> RedDBResult<Vec<super::index_store::RegisteredIndex>> {
157 let mut indexes = self
158 .index_store_ref()
159 .list_indices(collection)
160 .into_iter()
161 .filter(|index| index.unique)
162 .collect::<Vec<_>>();
163 if let Some(target) = target {
164 indexes.retain(|index| conflict_columns_match(&index.columns, target));
165 if indexes.is_empty() {
166 return Err(RedDBError::Query(format!(
167 "no unique index on collection '{}' matches ON CONFLICT ({})",
168 collection,
169 target.join(", ")
170 )));
171 }
172 }
173 Ok(indexes)
174 }
175
176 fn unique_index_conflict_id(
177 &self,
178 collection: &str,
179 fields: &[(String, Value)],
180 target: Option<&[String]>,
181 ) -> RedDBResult<Option<EntityId>> {
182 let indexes = self.conflict_unique_indexes(collection, target)?;
183 let Some(manager) = self.inner.db.store().get_collection(collection) else {
184 return Ok(None);
185 };
186 for index in indexes {
187 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
188 continue;
189 };
190 for entity in manager.query_all(|_| true) {
191 let Some(record) = runtime_any_record_from_entity_ref(&entity) else {
192 continue;
193 };
194 let existing_fields = record
195 .iter_fields()
196 .map(|(name, value)| (name.as_ref().to_string(), value.clone()))
197 .collect::<Vec<_>>();
198 if self.snapshot_manager().is_aborted(entity.xmin) {
199 self.index_store_ref()
200 .index_entity_delete(collection, entity.id, &existing_fields)
201 .map_err(RedDBError::Internal)?;
202 continue;
203 }
204 if conflict_index_key(&existing_fields, &index.columns)
205 .is_some_and(|existing_key| existing_key == proposed_key)
206 {
207 let conflicting_xid = entity.xmin;
208 if self.snapshot_manager().is_active(conflicting_xid)
209 && !self.own_transaction_xids().contains(&conflicting_xid)
210 {
211 return Err(RedDBError::Query(format!(
212 "serialization conflict: ON CONFLICT key in '{collection}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves"
213 )));
214 }
215 return Ok(Some(entity.id));
216 }
217 }
218 }
219 Ok(None)
220 }
221
222 fn on_conflict_unique_index_conflict_id(
223 &self,
224 collection: &str,
225 fields: &[(String, Value)],
226 target: Option<&[String]>,
227 ) -> RedDBResult<Option<EntityId>> {
228 let selected = self.unique_index_conflict_id(collection, fields, target)?;
229 if selected.is_some() || target.is_none() {
230 return Ok(selected);
231 }
232 if self
233 .unique_index_conflict_id(collection, fields, None)?
234 .is_some()
235 {
236 return Err(RedDBError::Query(format!(
237 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
238 target.unwrap_or_default().join(", ")
239 )));
240 }
241 Ok(None)
242 }
243
244 fn has_unique_index_conflict_with_fields(
245 &self,
246 collection: &str,
247 fields: &[(String, Value)],
248 existing_fields: &[Vec<(String, Value)>],
249 target: Option<&[String]>,
250 ) -> RedDBResult<bool> {
251 for index in self.conflict_unique_indexes(collection, target)? {
252 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
253 continue;
254 };
255 if existing_fields.iter().any(|existing| {
256 conflict_index_key(existing, &index.columns)
257 .is_some_and(|existing_key| existing_key == proposed_key)
258 }) {
259 return Ok(true);
260 }
261 }
262 Ok(false)
263 }
264
265 fn has_on_conflict_unique_index_conflict_with_fields(
266 &self,
267 collection: &str,
268 fields: &[(String, Value)],
269 existing_fields: &[Vec<(String, Value)>],
270 target: Option<&[String]>,
271 ) -> RedDBResult<bool> {
272 let selected = self.has_unique_index_conflict_with_fields(
273 collection,
274 fields,
275 existing_fields,
276 target,
277 )?;
278 if selected || target.is_none() {
279 return Ok(selected);
280 }
281 if self.has_unique_index_conflict_with_fields(collection, fields, existing_fields, None)? {
282 return Err(RedDBError::Query(format!(
283 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
284 target.unwrap_or_default().join(", ")
285 )));
286 }
287 Ok(false)
288 }
289
290 fn execute_unique_index_conflict_update(
291 &self,
292 raw_query: &str,
293 insert: &InsertQuery,
294 conflict_id: EntityId,
295 target: UpdateTarget,
296 excluded_fields: &[(String, Value)],
297 assignments: &[(String, Expr)],
298 ) -> RedDBResult<RuntimeQueryResult> {
299 let assignment_exprs = assignments
300 .iter()
301 .map(|(column, expr)| {
302 Ok((
303 column.clone(),
304 resolve_excluded_assignment(expr, excluded_fields)?,
305 ))
306 })
307 .collect::<RedDBResult<Vec<_>>>()?;
308 let literal_assignments = assignment_exprs
309 .iter()
310 .filter_map(|(column, expr)| {
311 fold_expr_to_value(expr.clone())
312 .ok()
313 .map(|value| (column.clone(), value))
314 })
315 .collect();
316 let update = UpdateQuery {
317 table: insert.table.clone(),
318 target,
319 compound_assignment_ops: vec![None; assignment_exprs.len()],
320 assignment_exprs,
321 assignments: literal_assignments,
322 where_expr: None,
323 filter: Some(Filter::Compare {
324 field: FieldRef::column("", "rid"),
325 op: CompareOp::Eq,
326 value: Value::UnsignedInteger(conflict_id.raw()),
327 }),
328 ttl_ms: None,
329 expires_at_ms: None,
330 with_metadata: Vec::new(),
331 returning: insert.returning.clone(),
332 claim_limit: None,
333 claim_exact: false,
334 order_by: Vec::new(),
335 limit: None,
336 suppress_events: insert.suppress_events,
337 };
338 self.execute_update(raw_query, &update)
339 }
340
341 fn infer_unmarked_document_insert(
360 &self,
361 query: &InsertQuery,
362 ) -> RedDBResult<Option<InsertQuery>> {
363 if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
364 return Ok(None);
365 }
366 match self
367 .db()
368 .collection_contract_arc(&query.table)
369 .map(|contract| contract.declared_model)
370 {
371 Some(crate::catalog::CollectionModel::Document) => {
372 let mut rewritten = query.clone();
373 rewritten.entity_type = InsertEntityType::Document;
374 rewritten.columns = vec!["body".to_string()];
375 Ok(Some(rewritten))
376 }
377 Some(other) => Err(RedDBError::InvalidOperation(format!(
378 "collection '{table}' is declared as '{model}'; the bare \
379 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
380 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
381 to insert into a {model} collection",
382 table = query.table,
383 model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
384 ))),
385 None => Err(RedDBError::InvalidOperation(format!(
386 "collection '{table}' does not exist and the INSERT carries no model marker; \
387 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
388 (idempotent), or run `CREATE DOCUMENT {table}` first",
389 table = query.table,
390 ))),
391 }
392 }
393
394 fn resolve_unmarked_update_target(
407 &self,
408 query: &UpdateQuery,
409 ) -> RedDBResult<Option<UpdateQuery>> {
410 if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
413 ensure_update_dotted_targets_allowed(query, false)?;
414 return Ok(None);
415 }
416 let declared_model = self
417 .db()
418 .collection_contract_arc(&query.table)
419 .map(|contract| contract.declared_model);
420 let is_document = matches!(
421 declared_model,
422 Some(crate::catalog::CollectionModel::Document)
423 );
424 ensure_update_dotted_targets_allowed(query, is_document)?;
425 let inferred = match declared_model {
426 Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
427 Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
428 _ => return Ok(None),
429 };
430 let mut rewritten = query.clone();
431 rewritten.target = inferred;
432 Ok(Some(rewritten))
433 }
434
435 pub fn execute_insert(
440 &self,
441 raw_query: &str,
442 query: &InsertQuery,
443 ) -> RedDBResult<RuntimeQueryResult> {
444 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
445 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
446 self,
447 &query.table,
448 );
449 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
450 crate::runtime::collection_contract::CollectionContractGate::check(
456 self,
457 &query.table,
458 crate::runtime::collection_contract::MutationKind::Insert,
459 )?;
460 let inferred_owned;
469 let query = match self.infer_unmarked_document_insert(query)? {
470 Some(rewritten) => {
471 inferred_owned = rewritten;
472 &inferred_owned
473 }
474 None => query,
475 };
476 let augmented_owned;
485 let query = match self.maybe_inject_tenant_column(query)? {
486 Some(new_q) => {
487 augmented_owned = new_q;
488 &augmented_owned
489 }
490 None => query,
491 };
492 if query.on_conflict.is_some()
493 && !matches!(
494 query.entity_type,
495 InsertEntityType::Row
496 | InsertEntityType::Document
497 | InsertEntityType::Node
498 | InsertEntityType::Edge
499 )
500 {
501 return Err(RedDBError::InvalidOperation(
502 "ON CONFLICT is supported for table rows, documents, graph nodes, and graph edges"
503 .to_string(),
504 ));
505 }
506 self.check_insert_column_policy(query)?;
507 if let Some(ref embed_config) = query.auto_embed {
508 let provider =
512 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
513 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
517 if matches!(provider, crate::ai::AiProvider::Local) {
518 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
519 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
527 if model_name.is_empty() {
528 return Err(RedDBError::Query(
529 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
530 the local provider does not have an implicit default model"
531 .to_string(),
532 ));
533 }
534 crate::runtime::ai::local_embedding::preflight_local_embedding(
535 &self.inner.db,
536 model_name,
537 )?;
538 }
539 }
540
541 let mut inserted_count: u64 = 0;
542 let effective_rows =
543 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
544 if effective_rows.len() > 1
545 && matches!(
546 query.on_conflict.as_ref().map(|clause| &clause.action),
547 Some(OnConflictAction::DoUpdate { .. })
548 )
549 {
550 return Err(RedDBError::InvalidOperation(
551 "multi-row ON CONFLICT DO UPDATE is not supported atomically; execute one proposed row per statement"
552 .to_string(),
553 ));
554 }
555
556 let store = self.inner.db.store();
558 let _ = store.get_or_create_collection(&query.table);
559 let declared_model = self
560 .db()
561 .collection_contract_arc(&query.table)
562 .map(|contract| contract.declared_model);
563 if query.on_conflict.is_some()
564 && matches!(
565 declared_model,
566 Some(crate::catalog::CollectionModel::TimeSeries)
567 )
568 {
569 return Err(RedDBError::InvalidOperation(
570 "ON CONFLICT is not supported for time-series collections".to_string(),
571 ));
572 }
573
574 let conflict_rmw_lock = query.on_conflict.as_ref().map(|_| {
579 self.inner
580 .rmw_locks
581 .lock_for(&query.table, "__collection_on_conflict_insert__")
582 });
583 let _conflict_rmw_guard = conflict_rmw_lock.as_ref().map(|lock| lock.lock());
584
585 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
586 if query.returning.is_some() {
587 Some(Vec::with_capacity(effective_rows.len()))
588 } else {
589 None
590 };
591 let mut returning_result: Option<UnifiedResult> = None;
592 let mut conflict_returning_records: Vec<UnifiedRecord> = Vec::new();
593 let mut auto_embed_ids = Vec::new();
594
595 if matches!(query.entity_type, InsertEntityType::Row)
596 && !matches!(
597 declared_model,
598 Some(crate::catalog::CollectionModel::TimeSeries)
599 )
600 {
601 let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
612 let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
613 Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
614 } else {
615 None
616 };
617 let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
618
619 if chain_mode && self.is_chain_integrity_broken(&query.table) {
622 return Err(RedDBError::InvalidOperation(format!(
623 "ChainIntegrityBroken: collection '{}' is locked until \
624 POST /collections/{}/clear-integrity-flag is called by an admin",
625 query.table, query.table
626 )));
627 }
628
629 let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
633 if chain_mode {
634 let mut cache = self.inner.chain_tip_cache.lock();
635 if let Some(existing) = cache.get(&query.table) {
636 Some(existing.clone())
637 } else if let Some(scanned) =
638 crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
639 {
640 cache.insert(query.table.clone(), scanned.clone());
641 Some(scanned)
642 } else {
643 None
644 }
645 } else {
646 None
647 };
648
649 let mut rows = Vec::with_capacity(effective_rows.len());
650 let mut accepted_row_fields: Vec<Vec<(String, Value)>> = Vec::new();
651 for row_values in &effective_rows {
652 if row_values.len() != query.columns.len() {
653 return Err(RedDBError::Query(format!(
654 "INSERT column count ({}) does not match value count ({})",
655 query.columns.len(),
656 row_values.len()
657 )));
658 }
659 let (mut fields, mut metadata) =
660 split_insert_metadata(self, &query.columns, row_values)?;
661 if query
662 .on_conflict
663 .as_ref()
664 .is_some_and(|clause| clause.target.is_none())
665 {
666 fields = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
667 self,
668 &self.db(),
669 &query.table,
670 )
671 .normalize_insert_fields(fields)?;
672 }
673 if let Some(clause) = &query.on_conflict {
674 let conflict_id = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
675 self,
676 &self.db(),
677 &query.table,
678 )
679 .row_uniqueness_conflict_id(&fields, clause.target.as_deref())?
680 .filter(|conflict_id| {
681 store.get(&query.table, *conflict_id).is_some_and(|entity| {
682 !self.snapshot_manager().is_aborted(entity.xmin)
683 })
684 });
685 if let Some(conflict_id) = conflict_id {
686 if let Some(conflicting) = store.get(&query.table, conflict_id) {
687 let conflicting_xid = conflicting.xmin;
688 if self.snapshot_manager().is_active(conflicting_xid)
689 && !self.own_transaction_xids().contains(&conflicting_xid)
690 {
691 return Err(RedDBError::Query(format!(
692 "serialization conflict: ON CONFLICT key in '{}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves",
693 query.table
694 )));
695 }
696 }
697 match &clause.action {
698 OnConflictAction::DoNothing => continue,
699 OnConflictAction::DoUpdate { assignments } => {
700 let assignment_exprs = assignments
701 .iter()
702 .map(|(column, expr)| {
703 Ok((
704 column.clone(),
705 resolve_excluded_assignment(expr, &fields)?,
706 ))
707 })
708 .collect::<RedDBResult<Vec<_>>>()?;
709 let literal_assignments = assignment_exprs
710 .iter()
711 .filter_map(|(column, expr)| {
712 fold_expr_to_value(expr.clone())
713 .ok()
714 .map(|value| (column.clone(), value))
715 })
716 .collect();
717 let update = UpdateQuery {
718 table: query.table.clone(),
719 target: UpdateTarget::Rows,
720 compound_assignment_ops: vec![None; assignment_exprs.len()],
721 assignment_exprs,
722 assignments: literal_assignments,
723 where_expr: None,
724 filter: Some(Filter::Compare {
725 field: FieldRef::column("", "rid"),
726 op: CompareOp::Eq,
727 value: Value::UnsignedInteger(
730 store
731 .get(&query.table, conflict_id)
732 .map(|entity| entity.logical_id().raw())
733 .unwrap_or(conflict_id.raw()),
734 ),
735 }),
736 ttl_ms: None,
737 expires_at_ms: None,
738 with_metadata: Vec::new(),
739 returning: query.returning.clone(),
740 claim_limit: None,
741 claim_exact: false,
742 order_by: Vec::new(),
743 limit: None,
744 suppress_events: query.suppress_events,
745 };
746 let updated = self.execute_update(raw_query, &update)?;
747 inserted_count += updated.affected_rows;
748 conflict_returning_records.extend(updated.result.records);
749 continue;
750 }
751 }
752 }
753 if matches!(&clause.action, OnConflictAction::DoNothing)
754 && crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
755 self,
756 &self.db(),
757 &query.table,
758 )
759 .has_row_uniqueness_conflict_with_rows(
760 &fields,
761 &accepted_row_fields,
762 clause.target.as_deref(),
763 )?
764 {
765 continue;
766 }
767 }
768 if chain_mode {
769 use crate::runtime::blockchain_kind::{
770 chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
771 COL_TIMESTAMP, RESERVED_COLUMNS,
772 };
773 let supplied_height = fields
774 .iter()
775 .find(|(k, _)| k == COL_BLOCK_HEIGHT)
776 .map(|(_, v)| v.clone());
777 let supplied_prev = fields
778 .iter()
779 .find(|(k, _)| k == COL_PREV_HASH)
780 .map(|(_, v)| v.clone());
781 let supplied_ts = fields
782 .iter()
783 .find(|(k, _)| k == COL_TIMESTAMP)
784 .map(|(_, v)| v.clone());
785 let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
786 let user_supplied_any = supplied_height.is_some()
787 || supplied_prev.is_some()
788 || supplied_ts.is_some()
789 || supplied_hash;
790
791 fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
792 let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
793
794 let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
795 Some(t) => (t.hash, t.height + 1),
796 None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
797 };
798 let server_now = crate::runtime::blockchain_kind::now_ms();
799
800 let (use_prev, use_height, use_ts) = if user_supplied_any {
801 if supplied_hash {
804 return Err(chain_conflict_error(
805 tip_next_height.saturating_sub(1),
806 tip_prev_hash,
807 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
808 server_now,
809 "hash column is engine-computed and cannot be supplied",
810 ));
811 }
812 let caller_prev = match &supplied_prev {
813 Some(Value::Blob(b)) if b.len() == 32 => {
814 let mut a = [0u8; 32];
815 a.copy_from_slice(b);
816 a
817 }
818 Some(Value::Text(s)) if s.len() == 64 => {
819 let mut a = [0u8; 32];
823 let mut ok = true;
824 for (i, slot) in a.iter_mut().enumerate() {
825 let pair = &s.as_ref()[i * 2..i * 2 + 2];
826 match u8::from_str_radix(pair, 16) {
827 Ok(byte) => *slot = byte,
828 Err(_) => {
829 ok = false;
830 break;
831 }
832 }
833 }
834 if !ok {
835 return Err(chain_conflict_error(
836 tip_next_height.saturating_sub(1),
837 tip_prev_hash,
838 chain_tip_full
839 .as_ref()
840 .map(|t| t.timestamp_ms)
841 .unwrap_or(0),
842 server_now,
843 "prev_hash is not valid hex",
844 ));
845 }
846 a
847 }
848 _ => {
849 return Err(chain_conflict_error(
850 tip_next_height.saturating_sub(1),
851 tip_prev_hash,
852 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
853 server_now,
854 "prev_hash missing or not a 32-byte Blob",
855 ));
856 }
857 };
858 if caller_prev != tip_prev_hash {
859 return Err(chain_conflict_error(
860 tip_next_height.saturating_sub(1),
861 tip_prev_hash,
862 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
863 server_now,
864 "prev_hash does not match current tip",
865 ));
866 }
867 let caller_height = match &supplied_height {
868 Some(Value::UnsignedInteger(v)) => *v,
869 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
870 _ => {
871 return Err(chain_conflict_error(
872 tip_next_height.saturating_sub(1),
873 tip_prev_hash,
874 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
875 server_now,
876 "block_height missing or not an unsigned integer",
877 ));
878 }
879 };
880 if caller_height != tip_next_height {
881 return Err(chain_conflict_error(
882 tip_next_height.saturating_sub(1),
883 tip_prev_hash,
884 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
885 server_now,
886 "block_height does not match tip+1",
887 ));
888 }
889 let caller_ts = match &supplied_ts {
890 Some(Value::UnsignedInteger(v)) => *v,
891 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
892 _ => {
893 return Err(chain_conflict_error(
894 tip_next_height.saturating_sub(1),
895 tip_prev_hash,
896 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
897 server_now,
898 "timestamp missing or not an unsigned integer",
899 ));
900 }
901 };
902 let drift = (caller_ts as i128) - (server_now as i128);
903 if drift.abs() > 60_000 {
904 return Err(chain_conflict_error(
905 tip_next_height.saturating_sub(1),
906 tip_prev_hash,
907 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
908 server_now,
909 "timestamp outside ±60s of server_time",
910 ));
911 }
912 (caller_prev, caller_height, caller_ts)
913 } else {
914 (tip_prev_hash, tip_next_height, server_now)
915 };
916
917 let (reserved, new_hash) =
918 crate::runtime::blockchain_kind::make_block_reserved_fields(
919 use_prev, use_height, use_ts, &payload,
920 );
921 fields.extend(reserved);
922 chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
923 height: use_height,
924 hash: new_hash,
925 timestamp_ms: use_ts,
926 });
927 }
928 if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
940 let (pk_col, sig_col, residual) =
941 crate::runtime::signed_writes_kind::split_signature_fields(fields);
942 let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
943 let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
944 crate::runtime::signed_writes_kind::verify_row(
945 ®,
946 pk_col.as_ref().map(|c| c.bytes.as_slice()),
947 sig_col.as_ref().map(|c| c.bytes.as_slice()),
948 &payload,
949 )
950 .map_err(crate::runtime::signed_writes_kind::map_error)?;
951 fields = residual;
952 if let Some(col) = pk_col {
957 fields.push((
958 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
959 col.raw_value,
960 ));
961 }
962 if let Some(col) = sig_col {
963 fields.push((
964 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
965 col.raw_value,
966 ));
967 }
968 }
969 merge_with_clauses(
970 &mut metadata,
971 query.ttl_ms,
972 query.expires_at_ms,
973 &query.with_metadata,
974 );
975 if let Some(snaps) = returning_snapshots.as_mut() {
976 snaps.push(fields.clone());
977 }
978 accepted_row_fields.push(fields.clone());
979 rows.push(CreateRowInput {
980 collection: query.table.clone(),
981 fields,
982 metadata,
983 node_links: Vec::new(),
984 vector_links: Vec::new(),
985 });
986 }
987 let batch = CreateRowsBatchInput {
988 collection: query.table.clone(),
989 rows,
990 suppress_events: query.suppress_events,
991 };
992 let outputs = if query
993 .on_conflict
994 .as_ref()
995 .is_some_and(|clause| clause.target.is_none())
996 {
997 self.create_rows_batch_prevalidated_with_outputs(batch)?
998 } else {
999 self.create_rows_batch(batch)?
1000 };
1001 inserted_count += outputs.len() as u64;
1002 if query.auto_embed.is_some() {
1003 auto_embed_ids.extend(outputs.iter().map(|output| output.id));
1004 }
1005
1006 if chain_mode {
1010 if let Some(new_tip) = chain_tip_full.as_ref() {
1011 self.inner
1012 .chain_tip_cache
1013 .lock()
1014 .insert(query.table.clone(), new_tip.clone());
1015 }
1016 }
1017
1018 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1026 let time_col = &spec.time_column;
1027 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1029 for row in &effective_rows {
1030 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1031 if *n >= 0 {
1032 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1033 }
1034 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1035 let _ = self.inner.db.hypertables().route(&query.table, *n);
1036 }
1037 }
1038 }
1039 }
1040
1041 if let (Some(items), Some(snaps)) =
1042 (query.returning.as_ref(), returning_snapshots.take())
1043 {
1044 let snaps = row_insert_returning_snapshots(&outputs, snaps);
1045 let mut result = build_returning_result(items, &snaps, Some(&outputs));
1046 result
1047 .records
1048 .splice(0..0, conflict_returning_records.drain(..));
1049 returning_result = Some(result);
1050 }
1051 } else {
1052 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1059 Vec::with_capacity(effective_rows.len());
1060 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1061 {
1062 Vec::with_capacity(effective_rows.len())
1063 } else {
1064 Vec::new()
1065 };
1066 if matches!(
1067 query.entity_type,
1068 InsertEntityType::Node | InsertEntityType::Edge
1069 ) {
1070 enum PreparedGraphInsert {
1071 Node {
1072 fields: Vec<(String, Value)>,
1073 input: CreateNodeInput,
1074 },
1075 Edge {
1076 fields: Vec<(String, Value)>,
1077 input: CreateEdgeInput,
1078 },
1079 }
1080
1081 let mut prepared = Vec::with_capacity(effective_rows.len());
1082 let mut accepted_graph_fields: Vec<Vec<(String, Value)>> = Vec::new();
1083 for row_values in &effective_rows {
1084 if row_values.len() != query.columns.len() {
1085 return Err(RedDBError::Query(format!(
1086 "INSERT column count ({}) does not match value count ({})",
1087 query.columns.len(),
1088 row_values.len()
1089 )));
1090 }
1091
1092 match query.entity_type {
1093 InsertEntityType::Node => {
1094 let (node_values, mut metadata) =
1095 split_insert_metadata(self, &query.columns, row_values)?;
1096 if let Some(clause) = &query.on_conflict {
1097 if let Some(conflict_id) = self
1098 .on_conflict_unique_index_conflict_id(
1099 &query.table,
1100 &node_values,
1101 clause.target.as_deref(),
1102 )?
1103 {
1104 match &clause.action {
1105 OnConflictAction::DoNothing => continue,
1106 OnConflictAction::DoUpdate { assignments } => {
1107 let updated = self
1108 .execute_unique_index_conflict_update(
1109 raw_query,
1110 query,
1111 conflict_id,
1112 UpdateTarget::Nodes,
1113 &node_values,
1114 assignments,
1115 )?;
1116 inserted_count += updated.affected_rows;
1117 conflict_returning_records
1118 .extend(updated.result.records);
1119 continue;
1120 }
1121 }
1122 }
1123 if matches!(&clause.action, OnConflictAction::DoNothing)
1124 && self.has_on_conflict_unique_index_conflict_with_fields(
1125 &query.table,
1126 &node_values,
1127 &accepted_graph_fields,
1128 clause.target.as_deref(),
1129 )?
1130 {
1131 continue;
1132 }
1133 }
1134 merge_with_clauses(
1135 &mut metadata,
1136 query.ttl_ms,
1137 query.expires_at_ms,
1138 &query.with_metadata,
1139 );
1140 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1141 apply_collection_default_ttl_metadata(
1142 self,
1143 &query.table,
1144 &mut metadata,
1145 );
1146 let (columns, values) = pairwise_columns_values(&node_values);
1147 let label = find_column_value_string(&columns, &values, "label")?;
1148 let node_type =
1149 find_column_value_opt_string(&columns, &values, "node_type");
1150 let properties = extract_remaining_properties(
1151 &columns,
1152 &values,
1153 &["label", "node_type"],
1154 );
1155 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1156 properties.iter().map(|(key, _)| key.as_str()),
1157 &format!("node '{}'", query.table),
1158 )?;
1159 accepted_graph_fields.push(node_values.clone());
1160 prepared.push(PreparedGraphInsert::Node {
1161 fields: node_values,
1162 input: CreateNodeInput {
1163 collection: query.table.clone(),
1164 label,
1165 node_type,
1166 properties,
1167 metadata,
1168 embeddings: Vec::new(),
1169 table_links: Vec::new(),
1170 node_links: Vec::new(),
1171 },
1172 });
1173 }
1174 InsertEntityType::Edge => {
1175 let (edge_values, mut metadata) =
1176 split_insert_metadata(self, &query.columns, row_values)?;
1177 if let Some(clause) = &query.on_conflict {
1178 if let Some(conflict_id) = self
1179 .on_conflict_unique_index_conflict_id(
1180 &query.table,
1181 &edge_values,
1182 clause.target.as_deref(),
1183 )?
1184 {
1185 match &clause.action {
1186 OnConflictAction::DoNothing => continue,
1187 OnConflictAction::DoUpdate { assignments } => {
1188 let updated = self
1189 .execute_unique_index_conflict_update(
1190 raw_query,
1191 query,
1192 conflict_id,
1193 UpdateTarget::Edges,
1194 &edge_values,
1195 assignments,
1196 )?;
1197 inserted_count += updated.affected_rows;
1198 conflict_returning_records
1199 .extend(updated.result.records);
1200 continue;
1201 }
1202 }
1203 }
1204 if matches!(&clause.action, OnConflictAction::DoNothing)
1205 && self.has_on_conflict_unique_index_conflict_with_fields(
1206 &query.table,
1207 &edge_values,
1208 &accepted_graph_fields,
1209 clause.target.as_deref(),
1210 )?
1211 {
1212 continue;
1213 }
1214 }
1215 merge_with_clauses(
1216 &mut metadata,
1217 query.ttl_ms,
1218 query.expires_at_ms,
1219 &query.with_metadata,
1220 );
1221 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1222 apply_collection_default_ttl_metadata(
1223 self,
1224 &query.table,
1225 &mut metadata,
1226 );
1227 let (columns, values) = pairwise_columns_values(&edge_values);
1228 let label = find_column_value_string(&columns, &values, "label")?;
1229 ensure_non_tree_structural_edge_label(&label)?;
1230 let from_id = resolve_edge_endpoint_any(
1231 self.inner.db.store().as_ref(),
1232 &query.table,
1233 &columns,
1234 &values,
1235 &["from_rid", "from"],
1236 )?;
1237 let to_id = resolve_edge_endpoint_any(
1238 self.inner.db.store().as_ref(),
1239 &query.table,
1240 &columns,
1241 &values,
1242 &["to_rid", "to"],
1243 )?;
1244 let weight = find_column_value_f32_opt(&columns, &values, "weight");
1245 let properties = extract_remaining_properties(
1246 &columns,
1247 &values,
1248 &["label", "from_rid", "to_rid", "from", "to", "weight"],
1249 );
1250 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1251 properties.iter().map(|(key, _)| key.as_str()),
1252 &format!("edge '{}'", query.table),
1253 )?;
1254 accepted_graph_fields.push(edge_values.clone());
1255 prepared.push(PreparedGraphInsert::Edge {
1256 fields: edge_values,
1257 input: CreateEdgeInput {
1258 collection: query.table.clone(),
1259 label,
1260 from: EntityId::new(from_id),
1261 to: EntityId::new(to_id),
1262 weight,
1263 properties,
1264 metadata,
1265 },
1266 });
1267 }
1268 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1269 }
1270 }
1271
1272 ensure_graph_insert_contract(self, &query.table)?;
1273 let mut batch = self.inner.db.batch();
1274 let mut graph_index_fields: Vec<Vec<(String, Value)>> =
1275 Vec::with_capacity(prepared.len());
1276 for item in prepared {
1277 match item {
1278 PreparedGraphInsert::Node { fields, input } => {
1279 if query.returning.is_some() {
1280 returning_field_snaps.push(fields.clone());
1281 }
1282 graph_index_fields.push(fields);
1283 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1284 batch = batch.add_node_with_type(
1285 input.collection,
1286 input.label,
1287 node_type,
1288 input.properties.into_iter().collect(),
1289 input.metadata.into_iter().collect(),
1290 );
1291 }
1292 PreparedGraphInsert::Edge { fields, input } => {
1293 if query.returning.is_some() {
1294 returning_field_snaps.push(fields.clone());
1295 }
1296 graph_index_fields.push(fields);
1297 batch = batch.add_edge(
1298 input.collection,
1299 input.label,
1300 input.from,
1301 input.to,
1302 input.weight.unwrap_or(1.0),
1303 input.properties.into_iter().collect(),
1304 input.metadata.into_iter().collect(),
1305 );
1306 }
1307 }
1308 }
1309 let batch_result = batch
1310 .execute()
1311 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1312 let (ids, entity_kind) = match query.entity_type {
1313 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1314 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1315 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1316 };
1317 for id in &ids {
1318 self.stamp_xmin_if_in_txn(&query.table, *id);
1319 }
1320 if !graph_index_fields.is_empty() {
1321 let index_rows: Vec<_> = ids
1322 .iter()
1323 .zip(graph_index_fields)
1324 .map(|(id, fields)| (*id, fields))
1325 .collect();
1326 self.inner
1327 .index_store
1328 .index_entity_insert_batch(&query.table, &index_rows)
1329 .map_err(RedDBError::Internal)?;
1330 }
1331 if query.returning.is_some() {
1332 returning_field_snaps = graph_insert_returning_snapshots(
1333 self.inner.db.store().as_ref(),
1334 &query.table,
1335 &ids,
1336 );
1337 }
1338 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1339 let store = self.inner.db.store();
1340 entity_outputs.extend(ids.iter().map(|id| {
1341 crate::application::entity::CreateEntityOutput {
1342 id: *id,
1343 entity: store.get(&query.table, *id),
1344 }
1345 }));
1346 inserted_count += ids.len() as u64;
1347 } else {
1348 for row_values in &effective_rows {
1349 if row_values.len() != query.columns.len() {
1350 return Err(RedDBError::Query(format!(
1351 "INSERT column count ({}) does not match value count ({})",
1352 query.columns.len(),
1353 row_values.len()
1354 )));
1355 }
1356
1357 match query.entity_type {
1358 InsertEntityType::Row => {
1359 if query.returning.is_some() {
1360 return Err(RedDBError::Query(
1361 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1362 .to_string(),
1363 ));
1364 }
1365 let (fields, mut metadata) =
1366 split_insert_metadata(self, &query.columns, row_values)?;
1367 merge_with_clauses(
1368 &mut metadata,
1369 query.ttl_ms,
1370 query.expires_at_ms,
1371 &query.with_metadata,
1372 );
1373 self.insert_timeseries_point(&query.table, fields, metadata)?;
1374 }
1375 InsertEntityType::Node | InsertEntityType::Edge => {
1376 unreachable!("NODE and EDGE are handled by the prepared graph path")
1377 }
1378 InsertEntityType::Vector => {
1379 let (vector_values, mut metadata) =
1380 split_insert_metadata(self, &query.columns, row_values)?;
1381 merge_with_clauses(
1382 &mut metadata,
1383 query.ttl_ms,
1384 query.expires_at_ms,
1385 &query.with_metadata,
1386 );
1387 let (columns, values) = pairwise_columns_values(&vector_values);
1388 let dense = find_column_value_vec_f32_any(
1389 &columns,
1390 &values,
1391 &["dense", "embedding"],
1392 )?;
1393 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1394 let content =
1395 find_column_value_opt_string(&columns, &values, "content");
1396 if query.returning.is_some() {
1397 returning_field_snaps.push(vector_values.clone());
1398 }
1399 let input = CreateVectorInput {
1400 collection: query.table.clone(),
1401 dense,
1402 content,
1403 metadata,
1404 link_row: None,
1405 link_node: None,
1406 };
1407 entity_outputs.push(self.create_vector(input)?);
1408 }
1409 InsertEntityType::Document => {
1410 let (document_values, mut metadata) =
1411 split_insert_metadata(self, &query.columns, row_values)?;
1412 let (columns, values) = pairwise_columns_values(&document_values);
1413 let body = find_document_body_json(&columns, &values)?;
1414 let conflict_fields =
1415 document_on_conflict_fields(&document_values, &body)?;
1416 if let Some(clause) = &query.on_conflict {
1417 if let Some(conflict_id) = self
1418 .on_conflict_unique_index_conflict_id(
1419 &query.table,
1420 &conflict_fields,
1421 clause.target.as_deref(),
1422 )?
1423 {
1424 match &clause.action {
1425 OnConflictAction::DoNothing => continue,
1426 OnConflictAction::DoUpdate { assignments } => {
1427 let updated = self
1428 .execute_unique_index_conflict_update(
1429 raw_query,
1430 query,
1431 conflict_id,
1432 UpdateTarget::Documents,
1433 &conflict_fields,
1434 assignments,
1435 )?;
1436 inserted_count += updated.affected_rows;
1437 conflict_returning_records
1438 .extend(updated.result.records);
1439 continue;
1440 }
1441 }
1442 }
1443 }
1444 merge_with_clauses(
1445 &mut metadata,
1446 query.ttl_ms,
1447 query.expires_at_ms,
1448 &query.with_metadata,
1449 );
1450 let input = CreateDocumentInput {
1451 collection: query.table.clone(),
1452 body,
1453 metadata,
1454 node_links: Vec::new(),
1455 vector_links: Vec::new(),
1456 };
1457 let output = self.create_document(input)?;
1458 if query.returning.is_some() {
1459 let fields = output
1460 .entity
1461 .as_ref()
1462 .map(entity_row_fields_snapshot)
1463 .filter(|fields| !fields.is_empty())
1464 .unwrap_or(document_values);
1465 returning_field_snaps.push(fields);
1466 }
1467 entity_outputs.push(output);
1468 }
1469 InsertEntityType::Kv => {
1470 let (kv_values, mut metadata) =
1471 split_insert_metadata(self, &query.columns, row_values)?;
1472 merge_with_clauses(
1473 &mut metadata,
1474 query.ttl_ms,
1475 query.expires_at_ms,
1476 &query.with_metadata,
1477 );
1478 let (columns, values) = pairwise_columns_values(&kv_values);
1479 let key = find_column_value_string(&columns, &values, "key")?;
1480 let value = find_column_value(&columns, &values, "value")?;
1481 if query.returning.is_some() {
1482 returning_field_snaps.push(kv_values.clone());
1483 }
1484 let input = CreateKvInput {
1485 collection: query.table.clone(),
1486 key,
1487 value,
1488 metadata,
1489 };
1490 entity_outputs.push(self.create_kv(input)?);
1491 }
1492 }
1493
1494 inserted_count += 1;
1495 }
1496 }
1497
1498 if query.auto_embed.is_some() {
1499 auto_embed_ids.extend(entity_outputs.iter().map(|output| output.id));
1500 }
1501
1502 if let Some(items) = query.returning.as_ref() {
1503 let mut result =
1504 build_returning_result(items, &returning_field_snaps, Some(&entity_outputs));
1505 result
1506 .records
1507 .splice(0..0, conflict_returning_records.drain(..));
1508 returning_result = Some(result);
1509 }
1510 }
1511
1512 if let Some(ref embed_config) = query.auto_embed {
1514 let store = self.inner.db.store();
1515 let provider =
1516 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
1517 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1518 let api_key = if is_local_provider {
1523 String::new()
1524 } else {
1525 crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
1526 };
1527 let model = crate::ai::resolve_embeddings_model_from_runtime(
1530 self,
1531 &provider,
1532 embed_config.model.as_deref(),
1533 );
1534
1535 let recent: Vec<_> = auto_embed_ids
1539 .iter()
1540 .filter_map(|id| store.get(&query.table, *id))
1541 .collect();
1542
1543 let entity_combos: Vec<(usize, String)> = recent
1545 .iter()
1546 .enumerate()
1547 .filter_map(|(i, entity)| {
1548 if let EntityData::Row(ref row) = entity.data {
1549 let texts: Vec<String> = embed_config
1550 .fields
1551 .iter()
1552 .filter_map(|field| match row.get_field(field) {
1553 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1554 _ => None,
1555 })
1556 .collect();
1557 if !texts.is_empty() {
1558 return Some((i, texts.join(" ")));
1559 }
1560 }
1561 None
1562 })
1563 .collect();
1564
1565 if !entity_combos.is_empty() {
1566 let batch_texts: Vec<String> =
1568 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1569
1570 let embeddings = if is_local_provider {
1580 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1581 &self.inner.db,
1582 &model,
1583 batch_texts,
1584 )?;
1585 response.embeddings
1586 } else {
1587 let batch_client =
1588 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1589
1590 match tokio::runtime::Handle::try_current() {
1591 Ok(handle) => tokio::task::block_in_place(|| {
1592 handle.block_on(batch_client.embed_batch(
1593 &provider,
1594 &model,
1595 &api_key,
1596 batch_texts,
1597 ))
1598 }),
1599 Err(_) => {
1600 return Err(RedDBError::Query(
1601 "AUTO EMBED requires a Tokio runtime context".to_string(),
1602 ));
1603 }
1604 }
1605 .map_err(|e| RedDBError::Query(e.to_string()))?
1606 };
1607
1608 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1610 if dense.is_empty() {
1611 continue;
1612 }
1613 self.create_vector(CreateVectorInput {
1614 collection: query.table.clone(),
1615 dense,
1616 content: Some(combined.clone()),
1617 metadata: Vec::new(),
1618 link_row: None,
1619 link_node: None,
1620 })?;
1621 }
1622 }
1623 }
1624
1625 if inserted_count > 0 {
1626 self.note_table_write(&query.table);
1627 }
1628
1629 let mut result = RuntimeQueryResult::dml_result(
1630 raw_query.to_string(),
1631 inserted_count,
1632 "insert",
1633 "runtime-dml",
1634 );
1635 if let Some(returning) = returning_result {
1636 result.result = returning;
1637 }
1638 Ok(result)
1639 }
1640
1641 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1642 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1643 return Ok(());
1644 };
1645 if !auth_store.iam_authorization_enabled() {
1646 return Ok(());
1647 }
1648 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1649 return Ok(());
1650 };
1651
1652 let tenant = crate::runtime::impl_core::current_tenant();
1653 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1654 let request = crate::auth::ColumnAccessRequest {
1655 action: "insert".to_string(),
1656 schema: None,
1657 table: query.table.clone(),
1658 columns: query.columns.clone(),
1659 };
1660 let ctx = crate::auth::policies::EvalContext {
1661 principal_tenant: tenant.clone(),
1662 current_tenant: tenant,
1663 peer_ip: None,
1664 mfa_present: false,
1665 now_ms: crate::auth::now_ms(),
1666 principal_is_admin_role: role == crate::auth::Role::Admin,
1667 principal_is_platform_scoped: principal.tenant.is_none(),
1668 };
1669
1670 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1671 let table_allowed = matches!(
1672 outcome.table_decision,
1673 crate::auth::policies::Decision::Allow { .. }
1674 | crate::auth::policies::Decision::AdminBypass
1675 );
1676 if !table_allowed {
1677 return Err(RedDBError::Query(format!(
1678 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1679 outcome.table_resource.kind, outcome.table_resource.name
1680 )));
1681 }
1682 if let Some(denied) = outcome.first_denied_column() {
1683 return Err(RedDBError::Query(format!(
1684 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1685 denied.resource.kind, denied.resource.name
1686 )));
1687 }
1688
1689 Ok(())
1690 }
1691
1692 pub(crate) fn insert_timeseries_point(
1693 &self,
1694 collection: &str,
1695 fields: Vec<(String, Value)>,
1696 mut metadata: Vec<(String, MetadataValue)>,
1697 ) -> RedDBResult<EntityId> {
1698 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1699
1700 let (columns, values) = pairwise_columns_values(&fields);
1701 validate_timeseries_insert_columns(&columns)?;
1702
1703 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1712 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1713 if let Some(event_name) = event_name_opt.as_deref() {
1714 let store_for_schema = self.inner.db.store();
1715 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1716 .is_some()
1717 {
1718 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1719 super::analytics_schema_registry::validate(
1720 store_for_schema.as_ref(),
1721 event_name,
1722 payload_json,
1723 )
1724 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1725 }
1726 }
1727
1728 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1733 Some(m) => m,
1734 None => event_name_opt.clone().ok_or_else(|| {
1735 RedDBError::Query(
1736 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1737 )
1738 })?,
1739 };
1740 let value = match find_column_value_opt_string(&columns, &values, "value") {
1744 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1745 None => columns
1746 .iter()
1747 .position(|c| c.eq_ignore_ascii_case("value"))
1748 .and_then(|i| match &values[i] {
1749 Value::Float(f) => Some(*f),
1750 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1751 Value::UnsignedInteger(n) => Some(*n as f64),
1752 _ => None,
1753 })
1754 .unwrap_or(1.0),
1755 };
1756 let timestamp_ns =
1757 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1758 let mut tags = find_timeseries_tags(&columns, &values)?;
1759 if let Some(ref name) = event_name_opt {
1760 tags.entry("event_name".to_string())
1761 .or_insert_with(|| name.clone());
1762 }
1763 if let Some(ref payload) = payload_opt {
1764 tags.entry("payload".to_string())
1765 .or_insert_with(|| payload.clone());
1766 }
1767 let fields = extract_remaining_properties(
1768 &columns,
1769 &values,
1770 &[
1771 "metric",
1772 "value",
1773 "tags",
1774 "timestamp",
1775 "timestamp_ns",
1776 "time",
1777 "event_name",
1778 "payload",
1779 ],
1780 );
1781 let index_fields = fields.clone();
1782 let _segment_reservation = self.admit_non_evictable_growth(
1783 crate::storage::memory_pools::MemoryPool::SegmentArena,
1784 &format!("insert into {collection}"),
1785 crate::runtime::memory_admission::estimate_timeseries_point_growth(
1786 &metric, &tags, &fields,
1787 ),
1788 )?;
1789 let indexed_columns = self
1790 .inner
1791 .index_store
1792 .list_indices(collection)
1793 .into_iter()
1794 .flat_map(|index| index.columns)
1795 .collect::<Vec<_>>();
1796 let _index_reservation = if indexed_columns.is_empty() {
1797 None
1798 } else {
1799 Some(self.admit_non_evictable_growth(
1800 crate::storage::memory_pools::MemoryPool::IndexMemory,
1801 &format!("index insert into {collection}"),
1802 crate::runtime::memory_admission::estimate_index_growth(
1803 std::slice::from_ref(&index_fields),
1804 &indexed_columns,
1805 ),
1806 )?)
1807 };
1808 let series_id = super::impl_timeseries::intern_timeseries_series(
1809 self.inner.db.store().as_ref(),
1810 collection,
1811 &metric,
1812 &tags,
1813 )?;
1814
1815 let mut entity = UnifiedEntity::new(
1816 EntityId::new(0),
1817 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1818 series: collection.to_string(),
1819 metric: metric.clone(),
1820 })),
1821 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1822 metric,
1823 series_id: Some(series_id),
1824 timestamp_ns,
1825 value,
1826 tags: HashMap::new(),
1827 fields: fields.into_iter().collect(),
1828 }),
1829 );
1830 let writer_xid = match self.current_xid() {
1834 Some(xid) => xid,
1835 None => {
1836 let mgr = self.snapshot_manager();
1837 let xid = mgr.begin();
1838 mgr.commit(xid);
1839 xid
1840 }
1841 };
1842 entity.set_xmin(writer_xid);
1843
1844 let store = self.inner.db.store();
1845 let id = store
1846 .insert_auto(collection, entity)
1847 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1848 self.inner
1849 .index_store
1850 .index_entity_insert(collection, id, &index_fields)
1851 .map_err(RedDBError::Internal)?;
1852
1853 if !metadata.is_empty() {
1854 let _ = store.set_metadata(
1855 collection,
1856 id,
1857 Metadata::with_fields(metadata.into_iter().collect()),
1858 );
1859 }
1860
1861 let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1862
1863 self.cdc_emit(
1864 crate::replication::cdc::ChangeOperation::Insert,
1865 collection,
1866 id.raw(),
1867 "timeseries",
1868 );
1869
1870 Ok(id)
1871 }
1872
1873 pub fn execute_update(
1878 &self,
1879 raw_query: &str,
1880 query: &UpdateQuery,
1881 ) -> RedDBResult<RuntimeQueryResult> {
1882 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1883 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
1884 self,
1885 &query.table,
1886 );
1887 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
1888 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1892 return Err(RedDBError::InvalidOperation(format!(
1893 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1894 query.table
1895 )));
1896 }
1897 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1904 return self.execute_queue_shaped_claim(raw_query, query);
1905 }
1906 let inferred_owned;
1914 let query = match self.resolve_unmarked_update_target(query)? {
1915 Some(rewritten) => {
1916 inferred_owned = rewritten;
1917 &inferred_owned
1918 }
1919 None => query,
1920 };
1921 crate::runtime::collection_contract::CollectionContractGate::check(
1927 self,
1928 &query.table,
1929 crate::runtime::collection_contract::MutationKind::Update,
1930 )?;
1931 ensure_update_target_contract(self, &query.table, query.target)?;
1932 ensure_kv_key_update_target_allowed(query)?;
1933 ensure_graph_identity_update_target_allowed(query)?;
1934
1935 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1941 let augmented_query: UpdateQuery;
1942 let effective_query: &UpdateQuery = if rls_gated {
1943 let update_filter = crate::runtime::impl_core::rls_policy_filter(
1944 self,
1945 &query.table,
1946 reddb_rql::ast::PolicyAction::Update,
1947 );
1948 let Some(mut policy) = update_filter else {
1949 let mut response = RuntimeQueryResult::dml_result(
1952 raw_query.to_string(),
1953 0,
1954 "update",
1955 "runtime-dml-rls",
1956 );
1957 if let Some(items) = query.returning.clone() {
1958 response.result = build_returning_result(&items, &[], None);
1959 }
1960 return Ok(response);
1961 };
1962 if query.claim_limit.is_some() {
1963 let read_filter = crate::runtime::impl_core::rls_policy_filter(
1964 self,
1965 &query.table,
1966 reddb_rql::ast::PolicyAction::Select,
1967 );
1968 let Some(read_policy) = read_filter else {
1969 let mut response = RuntimeQueryResult::dml_result(
1970 raw_query.to_string(),
1971 0,
1972 "update",
1973 "runtime-dml-rls",
1974 );
1975 if let Some(items) = query.returning.clone() {
1976 response.result = build_returning_result(&items, &[], None);
1977 }
1978 return Ok(response);
1979 };
1980 policy = reddb_rql::ast::Filter::And(Box::new(read_policy), Box::new(policy));
1981 }
1982 let mut augmented = query.clone();
1983 augmented.filter = Some(match augmented.filter.take() {
1984 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
1985 None => policy,
1986 });
1987 augmented_query = augmented;
1988 &augmented_query
1989 } else {
1990 query
1991 };
1992
1993 if let Some(items) = effective_query.returning.clone() {
1998 let mut inner_query = effective_query.clone();
1999 inner_query.returning = None;
2000 let (mut response, touched_ids) =
2001 self.execute_update_inner_tracked(raw_query, &inner_query)?;
2002
2003 let mut snapshots = if matches!(
2004 effective_query.target,
2005 UpdateTarget::Nodes | UpdateTarget::Edges
2006 ) {
2007 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
2008 } else {
2009 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
2010 .row_snapshots(&touched_ids)
2011 };
2012 if matches!(effective_query.target, UpdateTarget::Kv) {
2013 restore_kv_returning_keys(
2014 self,
2015 &effective_query.table,
2016 &touched_ids,
2017 &mut snapshots,
2018 );
2019 }
2020
2021 response.result = build_returning_result(&items, &snapshots, None);
2022 response.engine = "runtime-dml-returning";
2023 return Ok(response);
2024 }
2025
2026 self.execute_update_inner(raw_query, effective_query)
2027 }
2028
2029 fn execute_update_inner(
2031 &self,
2032 raw_query: &str,
2033 query: &UpdateQuery,
2034 ) -> RedDBResult<RuntimeQueryResult> {
2035 self.execute_update_inner_tracked(raw_query, query)
2036 .map(|(res, _)| res)
2037 }
2038
2039 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
2048 if query.claim_limit.is_none()
2049 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
2050 {
2051 return Ok(());
2052 }
2053 let available_indexes: Vec<Vec<String>> = self
2054 .index_store_ref()
2055 .list_indices(&query.table)
2056 .into_iter()
2057 .map(|index| index.columns)
2058 .collect();
2059 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
2060 .map_err(RedDBError::InvalidOperation)
2061 }
2062
2063 fn execute_update_inner_tracked(
2064 &self,
2065 raw_query: &str,
2066 query: &UpdateQuery,
2067 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2068 self.enforce_claim_order_by_index_gate(query)?;
2069 let store = self.inner.db.store();
2070 let topology_lock = self
2071 .index_store_ref()
2072 .collection_topology_lock(&query.table);
2073 let effective_filter = effective_update_filter(query);
2074 let compiled_plan = self.compile_update_plan(query)?;
2075 let needs_rmw_lock = update_needs_rmw_lock(query);
2076 let claim_model = update_model_name(update_target_model(query.target));
2077 if query.claim_limit.is_some() {
2078 self.inner
2079 .claim_telemetry
2080 .record_attempt(&query.table, claim_model);
2081 }
2082 let claim_lock = query.claim_limit.map(|_| {
2083 self.inner
2084 .rmw_locks
2085 .lock_for(&query.table, "__table_claim_update__")
2086 });
2087 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2088 let Some(guard) = lock.try_lock() else {
2089 let skipped_locked = query.claim_limit.unwrap_or(1);
2090 self.inner.claim_telemetry.record_skipped_locked(
2091 &query.table,
2092 claim_model,
2093 skipped_locked,
2094 );
2095 tracing::debug!(
2096 target: "reddb::claim",
2097 collection = %query.table,
2098 model = claim_model,
2099 skipped_locked,
2100 "concurrent claim skipped locked candidates"
2101 );
2102 return Ok((
2103 RuntimeQueryResult::dml_result(
2104 raw_query.to_string(),
2105 0,
2106 "update",
2107 "runtime-dml",
2108 ),
2109 Vec::new(),
2110 ));
2111 };
2112 Some(guard)
2113 } else {
2114 None
2115 };
2116 let table_rmw_lock = if needs_rmw_lock {
2117 Some(
2118 self.inner
2119 .rmw_locks
2120 .lock_for(&query.table, "__table_rmw_update__"),
2121 )
2122 } else {
2123 None
2124 };
2125 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2126 let mut touched_ids: Vec<EntityId> = Vec::new();
2127 let claim_cap = query.claim_limit.map(|limit| limit as usize);
2128 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2129 let manager = store
2130 .get_collection(&query.table)
2131 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2132 let scan_limit = if query.order_by.is_empty() {
2133 limit_cap
2134 } else {
2135 None
2136 };
2137 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2138 self,
2139 &query.table,
2140 effective_filter.as_ref(),
2141 scan_limit,
2142 query.target,
2143 );
2144 if needs_rmw_lock {
2145 target_scan = target_scan.with_live_table_rows();
2146 }
2147 let ids_to_update = {
2148 let _topology_guard = topology_lock.read();
2149 target_scan.find_target_ids()?
2150 };
2151 let order_limit = if query.claim_limit.is_some() {
2152 None
2153 } else {
2154 limit_cap
2155 };
2156 let ids_to_update = if query.order_by.is_empty() {
2157 ids_to_update
2158 } else {
2159 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
2160 };
2161 let mut ids_to_update = if query.claim_limit.is_some() {
2162 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
2163 } else {
2164 ids_to_update
2165 };
2166 if let Some(claim_cap) = claim_cap {
2167 ids_to_update.truncate(claim_cap);
2168 }
2169 if query.claim_exact
2170 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2171 {
2172 self.inner
2173 .claim_telemetry
2174 .record_miss(&query.table, claim_model);
2175 return Ok((
2176 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2177 Vec::new(),
2178 ));
2179 }
2180
2181 if needs_rmw_lock {
2182 let result = self.execute_update_inner_tracked_locked(
2183 raw_query,
2184 query,
2185 &compiled_plan,
2186 &ids_to_update,
2187 effective_filter.as_ref(),
2188 )?;
2189 if query.claim_limit.is_some() {
2190 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2191 }
2192 record_claim_outcome(
2193 &self.inner.claim_telemetry,
2194 query.claim_limit,
2195 &query.table,
2196 claim_model,
2197 result.0.affected_rows,
2198 );
2199 return Ok(result);
2200 }
2201
2202 let topology_guard = topology_lock.read();
2206 let mut prepared = Vec::new();
2207 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2208 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2209 for entity in manager.get_many(chunk).into_iter().flatten() {
2210 let assignments =
2211 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2212 let applied = self.apply_materialized_update_for_entity(
2213 query,
2214 entity,
2215 &compiled_plan,
2216 assignments,
2217 )?;
2218 reservations.admit(&applied)?;
2219 touched_ids.push(applied.id);
2220 prepared.push(applied);
2221 }
2222 }
2223 let affected = prepared.len() as u64;
2224 let mut published = 0;
2225 let persistence = (|| -> RedDBResult<()> {
2226 for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2227 if compiled_plan.row_touches_unique_columns
2228 || compiled_plan
2229 .row_contract_plan
2230 .as_ref()
2231 .is_some_and(|plan| plan.has_expressions)
2232 {
2233 let db = self.db();
2234 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2235 for item in chunk {
2236 let fields =
2237 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2238 let previous_id =
2239 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2240 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2241 }
2242 }
2243 self.persist_update_chunk(chunk)?;
2244 published += chunk.len();
2245 }
2246 Ok(())
2247 })();
2248 let published_mutations = &prepared[..published];
2251 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2252 if !query.suppress_events {
2253 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2254 }
2255 persistence?;
2256 drop(reservations);
2257
2258 if affected > 0 {
2259 self.note_table_write(&query.table);
2260 }
2261 if query.claim_limit.is_some() {
2262 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2263 }
2264 record_claim_outcome(
2265 &self.inner.claim_telemetry,
2266 query.claim_limit,
2267 &query.table,
2268 claim_model,
2269 affected,
2270 );
2271
2272 Ok((
2273 RuntimeQueryResult::dml_result(
2274 raw_query.to_string(),
2275 affected,
2276 "update",
2277 "runtime-dml",
2278 ),
2279 touched_ids,
2280 ))
2281 }
2282
2283 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2284 let conn_id = current_connection_id();
2285 let locks = self.inner.pending_claim_locks.read();
2286 ids.into_iter()
2287 .filter(|id| {
2288 let Some(entity) = self.inner.db.store().get(table, *id) else {
2289 return false;
2290 };
2291 let key = (table.to_string(), entity.logical_id());
2292 locks
2293 .get(&key)
2294 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2295 })
2296 .collect()
2297 }
2298
2299 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2300 if self.current_xid().is_none() || ids.is_empty() {
2301 return;
2302 }
2303
2304 let conn_id = current_connection_id();
2305 let store = self.inner.db.store();
2306 let mut locks = self.inner.pending_claim_locks.write();
2307 for id in ids {
2308 let Some(entity) = store.get(table, *id) else {
2309 continue;
2310 };
2311 locks.insert((table.to_string(), entity.logical_id()), conn_id);
2312 }
2313 }
2314
2315 fn execute_update_inner_tracked_locked(
2316 &self,
2317 raw_query: &str,
2318 query: &UpdateQuery,
2319 compiled_plan: &CompiledUpdatePlan,
2320 ids_to_update: &[EntityId],
2321 effective_filter: Option<&Filter>,
2322 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2323 let topology_lock = self
2326 .index_store_ref()
2327 .collection_topology_lock(&query.table);
2328 let topology_guard = topology_lock.read();
2329 let store = self.inner.db.store();
2330 let mut touched_ids = Vec::new();
2331 let mut lock_entries = Vec::new();
2332
2333 for id in ids_to_update {
2334 let Some(candidate) = store.get(&query.table, *id) else {
2335 continue;
2336 };
2337 let logical_id = candidate.logical_id();
2338 let lock_key = format!("row:{}", logical_id.raw());
2339 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2340 lock_entries.push((lock_key, logical_id, rmw_lock));
2341 }
2342
2343 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2344 lock_entries.dedup_by(|left, right| left.0 == right.0);
2345 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2346
2347 let mut applied_chunk = Vec::new();
2348 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2349 for (_, logical_id, _) in &lock_entries {
2350 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2351 else {
2352 continue;
2353 };
2354 if let Some(filter) = effective_filter {
2355 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2356 Some(self.inner.db.as_ref()),
2357 &entity,
2358 filter,
2359 &query.table,
2360 &query.table,
2361 ) {
2362 continue;
2363 }
2364 }
2365
2366 let assignments =
2367 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2368 let applied = self.apply_materialized_update_for_entity(
2369 query,
2370 entity,
2371 compiled_plan,
2372 assignments,
2373 )?;
2374 reservations.admit(&applied)?;
2375 touched_ids.push(applied.id);
2376 applied_chunk.push(applied);
2377 }
2378
2379 let affected = applied_chunk.len() as u64;
2380 if !applied_chunk.is_empty() {
2381 self.persist_update_chunk(&applied_chunk)?;
2382 let lsns = self.flush_update_chunk(&applied_chunk, topology_guard)?;
2383 if !query.suppress_events {
2384 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2385 }
2386 }
2387
2388 if affected > 0 {
2389 self.note_table_write(&query.table);
2390 }
2391
2392 Ok((
2393 RuntimeQueryResult::dml_result(
2394 raw_query.to_string(),
2395 affected,
2396 "update",
2397 "runtime-dml",
2398 ),
2399 touched_ids,
2400 ))
2401 }
2402
2403 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2404 let mut static_field_assignments = Vec::new();
2405 let mut static_metadata_assignments = Vec::new();
2406 let mut dynamic_assignments = Vec::new();
2407 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2408 let mut row_modified_columns = Vec::new();
2409
2410 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2411 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2412 let metadata_key = resolve_sql_ttl_metadata_key(column);
2413 if compound_op.is_some() && metadata_key.is_some() {
2414 return Err(RedDBError::Query(format!(
2415 "compound assignment is only supported for row fields: {column}"
2416 )));
2417 }
2418 if compound_op.is_none() {
2419 if let Ok(value) = fold_expr_to_value(expr.clone()) {
2420 if let Some(metadata_key) = metadata_key {
2421 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2422 let (canonical_key, canonical_value) =
2423 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2424 static_metadata_assignments
2425 .push((canonical_key.to_string(), canonical_value));
2426 } else {
2427 let value = self.resolve_crypto_sentinel(value)?;
2428 static_field_assignments.push((
2429 column.clone(),
2430 normalize_row_update_assignment_with_plan(
2431 &query.table,
2432 column,
2433 value,
2434 row_contract_plan.as_ref(),
2435 )?,
2436 ));
2437 row_modified_columns.push(column.clone());
2438 }
2439 continue;
2440 }
2441 }
2442
2443 dynamic_assignments.push(CompiledUpdateAssignment {
2444 column: column.clone(),
2445 expr: expr.clone(),
2446 compound_op,
2447 metadata_key,
2448 row_rule: if metadata_key.is_none() {
2449 if let Some(plan) = row_contract_plan.as_ref() {
2450 if plan.timestamps_enabled
2451 && (column == "created_at" || column == "updated_at")
2452 {
2453 return Err(RedDBError::Query(format!(
2454 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2455 query.table, column
2456 )));
2457 }
2458 if let Some(rule) = plan.declared_rules.get(column) {
2459 Some(rule.clone())
2460 } else if plan.strict_schema {
2461 return Err(RedDBError::Query(format!(
2462 "collection '{}' is strict and does not allow undeclared fields: {}",
2463 query.table, column
2464 )));
2465 } else {
2466 None
2467 }
2468 } else {
2469 None
2470 }
2471 } else {
2472 None
2473 },
2474 });
2475 if metadata_key.is_none() {
2476 row_modified_columns.push(column.clone());
2477 }
2478 }
2479
2480 let row_modified_columns = dedupe_update_columns(row_modified_columns);
2481 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2482 row_modified_columns.iter().any(|column| {
2483 plan.unique_columns
2484 .keys()
2485 .any(|unique| unique.eq_ignore_ascii_case(column))
2486 })
2487 });
2488
2489 if let Some(ttl_ms) = query.ttl_ms {
2490 static_metadata_assignments
2491 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2492 }
2493 if let Some(expires_at_ms) = query.expires_at_ms {
2494 static_metadata_assignments.push((
2495 "_expires_at".to_string(),
2496 metadata_u64_to_value(expires_at_ms),
2497 ));
2498 }
2499 for (key, val) in &query.with_metadata {
2500 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2501 }
2502
2503 Ok(CompiledUpdatePlan {
2504 static_field_assignments,
2505 static_metadata_assignments,
2506 dynamic_assignments,
2507 row_contract_plan,
2508 row_modified_columns,
2509 row_touches_unique_columns,
2510 })
2511 }
2512
2513 fn materialize_update_assignments_for_entity(
2514 &self,
2515 query: &UpdateQuery,
2516 entity: &UnifiedEntity,
2517 compiled_plan: &CompiledUpdatePlan,
2518 ) -> RedDBResult<MaterializedUpdateAssignments> {
2519 let mut assignments = MaterializedUpdateAssignments::default();
2520 let mut record: Option<UnifiedRecord> = None;
2521
2522 for assignment in &compiled_plan.dynamic_assignments {
2523 if assignment.compound_op.is_some()
2524 && !matches!(
2525 entity.data,
2526 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2527 )
2528 {
2529 return Err(RedDBError::Query(format!(
2530 "compound assignment is only supported for row or graph UPDATE column '{}'",
2531 assignment.column
2532 )));
2533 }
2534 if record.is_none() {
2535 record = runtime_any_record_from_entity_ref(entity);
2536 }
2537 let Some(record) = record.as_ref() else {
2538 return Err(RedDBError::Query(format!(
2539 "UPDATE could not materialize runtime record for entity {} in '{}'",
2540 entity.id.raw(),
2541 query.table
2542 )));
2543 };
2544 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2545 Some(self.inner.db.as_ref()),
2546 &assignment.expr,
2547 record,
2548 Some(query.table.as_str()),
2549 Some(query.table.as_str()),
2550 )
2551 .ok_or_else(|| {
2552 RedDBError::Query(format!(
2553 "failed to evaluate UPDATE expression for column '{}'",
2554 assignment.column
2555 ))
2556 })?;
2557 let value = if let Some(op) = assignment.compound_op {
2558 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2559 } else {
2560 rhs
2561 };
2562
2563 if let Some(metadata_key) = assignment.metadata_key {
2564 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2565 let (canonical_key, canonical_value) =
2566 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2567 assignments
2568 .dynamic_metadata_assignments
2569 .push((canonical_key.to_string(), canonical_value));
2570 } else {
2571 assignments.dynamic_field_assignments.push((
2572 assignment.column.clone(),
2573 normalize_row_update_value_for_rule(
2574 &query.table,
2575 self.resolve_crypto_sentinel(value)?,
2576 assignment.row_rule.as_ref(),
2577 )?,
2578 ));
2579 }
2580 }
2581
2582 Ok(assignments)
2583 }
2584
2585 fn apply_materialized_update_for_entity(
2586 &self,
2587 query: &UpdateQuery,
2588 entity: UnifiedEntity,
2589 compiled_plan: &CompiledUpdatePlan,
2590 mut assignments: MaterializedUpdateAssignments,
2591 ) -> RedDBResult<AppliedEntityMutation> {
2592 if matches!(query.target, UpdateTarget::Kv)
2593 && !compiled_plan
2594 .static_field_assignments
2595 .iter()
2596 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2597 && !assignments
2598 .dynamic_field_assignments
2599 .iter()
2600 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2601 {
2602 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2603 .and_then(|record| record.get("key").cloned())
2604 {
2605 assignments
2606 .dynamic_field_assignments
2607 .push(("key".to_string(), key));
2608 }
2609 }
2610 if matches!(entity.data, EntityData::Row(_)) {
2611 return self.apply_loaded_sql_update_row_core(
2612 query.table.clone(),
2613 entity,
2614 &compiled_plan.static_field_assignments,
2615 assignments.dynamic_field_assignments,
2616 &compiled_plan.static_metadata_assignments,
2617 assignments.dynamic_metadata_assignments,
2618 compiled_plan.row_contract_plan.as_ref(),
2619 &compiled_plan.row_modified_columns,
2620 compiled_plan.row_touches_unique_columns,
2621 );
2622 }
2623
2624 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2625
2626 let operations = build_patch_operations_from_materialized_assignments(
2627 &entity,
2628 compiled_plan,
2629 assignments,
2630 );
2631 self.apply_loaded_patch_entity_core(
2632 query.table.clone(),
2633 entity,
2634 crate::json::Value::Null,
2635 operations,
2636 )
2637 }
2638
2639 pub fn execute_delete(
2641 &self,
2642 raw_query: &str,
2643 query: &DeleteQuery,
2644 ) -> RedDBResult<RuntimeQueryResult> {
2645 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2646 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2647 self,
2648 &query.table,
2649 );
2650 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2651 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2654 return Err(RedDBError::InvalidOperation(format!(
2655 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2656 query.table
2657 )));
2658 }
2659 crate::runtime::collection_contract::CollectionContractGate::check(
2663 self,
2664 &query.table,
2665 crate::runtime::collection_contract::MutationKind::Delete,
2666 )?;
2667
2668 if let Some(items) = query.returning.clone() {
2675 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2676 RedDBError::Query(
2677 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2678 )
2679 })?;
2680 let captured = self.execute_query(&select_sql)?;
2681
2682 let mut inner_query = query.clone();
2683 inner_query.returning = None;
2684 let _ = self.execute_delete(raw_query, &inner_query)?;
2685
2686 let snapshots: Vec<Vec<(String, Value)>> = captured
2687 .result
2688 .records
2689 .iter()
2690 .map(|rec| {
2691 rec.iter_fields()
2692 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2693 .collect()
2694 })
2695 .collect();
2696 let affected = snapshots.len() as u64;
2697 let result = build_returning_result(&items, &snapshots, None);
2698
2699 let mut response = RuntimeQueryResult::dml_result(
2700 raw_query.to_string(),
2701 affected,
2702 "delete",
2703 "runtime-dml-returning",
2704 );
2705 response.result = result;
2706 return Ok(response);
2707 }
2708 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2715 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2716 self,
2717 &query.table,
2718 reddb_rql::ast::PolicyAction::Delete,
2719 );
2720 let Some(policy) = rls_filter else {
2721 return Ok(RuntimeQueryResult::dml_result(
2722 raw_query.to_string(),
2723 0,
2724 "delete",
2725 "runtime-dml-rls",
2726 ));
2727 };
2728 let mut augmented = query.clone();
2733 augmented.filter = Some(match augmented.filter.take() {
2734 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2735 None => policy,
2736 });
2737 return self.execute_delete_inner(raw_query, &augmented);
2738 }
2739 self.execute_delete_inner(raw_query, query)
2740 }
2741
2742 fn execute_delete_inner(
2743 &self,
2744 raw_query: &str,
2745 query: &DeleteQuery,
2746 ) -> RedDBResult<RuntimeQueryResult> {
2747 let topology_lock = self
2748 .index_store_ref()
2749 .collection_topology_lock(&query.table);
2750 let effective_filter = effective_delete_filter(query);
2751
2752 let scan = super::dml_target_scan::DmlTargetScan::new(
2756 self,
2757 &query.table,
2758 effective_filter.as_ref(),
2759 None,
2760 );
2761 let ids_to_delete = {
2762 let _topology_guard = topology_lock.read();
2763 scan.find_target_ids()?
2764 };
2765
2766 let needs_delete_events =
2769 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2770 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2771 scan.row_json_pre_images(&ids_to_delete)
2772 } else {
2773 HashMap::new()
2774 };
2775
2776 let mut affected: u64 = 0;
2777 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2778 let (count, lsns) =
2779 self.delete_entities_batch(&query.table, chunk, topology_lock.read())?;
2780 #[cfg(test)]
2781 self.index_store_ref().mutation_test_hook(
2782 &query.table,
2783 super::index_store::MutationTestPhase::BeforeEvents,
2784 );
2785 affected += count;
2786 if needs_delete_events && !lsns.is_empty() {
2787 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2791 self.emit_delete_events_for_collection(
2792 &query.table,
2793 deleted_chunk,
2794 &lsns,
2795 &pre_images,
2796 )?;
2797 }
2798 }
2799 pre_images.clear();
2800
2801 if affected > 0 {
2802 self.note_table_write(&query.table);
2803 }
2804
2805 Ok(RuntimeQueryResult::dml_result(
2806 raw_query.to_string(),
2807 affected,
2808 "delete",
2809 "runtime-dml",
2810 ))
2811 }
2812}
2813
2814#[cfg(test)]
2815mod tests {
2816 use crate::storage::wal::{WalReader, WalRecord};
2817 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2818 use crate::{RedDBOptions, RedDBRuntime};
2819 use reddb_types::Value;
2820 use std::path::Path;
2821
2822 fn persistent_operational_options(path: &Path) -> RedDBOptions {
2823 RedDBOptions::persistent(path)
2824 .with_storage_profile(StorageProfileSelection {
2825 deploy_profile: DeployProfile::Embedded,
2826 packaging: StoragePackaging::OperationalDirectory,
2827 replica_count: 0,
2828 managed_backup: false,
2829 wal_retention: false,
2830 })
2831 .unwrap()
2832 }
2833
2834 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
2835 WalReader::open(wal_path)
2836 .expect("wal opens")
2837 .iter()
2838 .map(|record| record.expect("wal record decodes").1)
2839 .filter_map(|record| match record {
2840 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
2841 _ => None,
2842 })
2843 .collect()
2844 }
2845
2846 fn action_contains_text(action: &[u8], needle: &str) -> bool {
2847 action
2848 .windows(needle.len())
2849 .any(|window| window == needle.as_bytes())
2850 }
2851
2852 fn claim_metric_count(
2853 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
2854 metric: &str,
2855 collection: &str,
2856 model: &str,
2857 ) -> u64 {
2858 let rows = match metric {
2859 "attempts" => &snapshot.attempts,
2860 "successful" => &snapshot.successful,
2861 "misses" => &snapshot.misses,
2862 "skipped_locked" => &snapshot.skipped_locked,
2863 other => panic!("unknown claim metric {other}"),
2864 };
2865 rows.iter()
2866 .find(|((actual_collection, actual_model), _)| {
2867 actual_collection == collection && actual_model == model
2868 })
2869 .map(|(_, count)| *count)
2870 .unwrap_or(0)
2871 }
2872
2873 fn assert_statement_writes_collections_in_one_new_wal_batch(
2874 rt: &RedDBRuntime,
2875 wal_path: &Path,
2876 statement: &str,
2877 source: &str,
2878 event_queue: &str,
2879 ) {
2880 let before_batches = store_commit_batches(wal_path).len();
2881
2882 rt.execute_query(statement).unwrap();
2883
2884 let batches = store_commit_batches(wal_path);
2885 let statement_batches = &batches[before_batches..];
2886 let source_batch = statement_batches
2887 .iter()
2888 .position(|actions| {
2889 actions.iter().any(|action| {
2890 action_contains_text(action, source)
2891 && !action_contains_text(action, event_queue)
2892 })
2893 })
2894 .expect("source collection write batch is present");
2895 let event_batch = statement_batches
2896 .iter()
2897 .position(|actions| {
2898 actions
2899 .iter()
2900 .any(|action| action_contains_text(action, event_queue))
2901 })
2902 .expect("event queue write batch is present");
2903
2904 assert_eq!(
2905 source_batch, event_batch,
2906 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
2907 );
2908 }
2909
2910 #[test]
2911 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
2912 let dir = tempfile::tempdir().unwrap();
2913 let db_path = dir.path().join("events_dual_write.rdb");
2914 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2915 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2916
2917 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2918 .unwrap();
2919 assert_statement_writes_collections_in_one_new_wal_batch(
2920 &rt,
2921 &wal_path,
2922 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
2923 "users",
2924 "users_events",
2925 );
2926 }
2927
2928 #[test]
2929 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
2930 let dir = tempfile::tempdir().unwrap();
2931 let db_path = dir.path().join("events_update_atomic.rdb");
2932 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2933 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2934
2935 rt.execute_query(
2936 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
2937 )
2938 .unwrap();
2939 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2940 .unwrap();
2941
2942 assert_statement_writes_collections_in_one_new_wal_batch(
2943 &rt,
2944 &wal_path,
2945 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
2946 "users",
2947 "user_updates",
2948 );
2949 }
2950
2951 #[test]
2952 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
2953 let dir = tempfile::tempdir().unwrap();
2954 let db_path = dir.path().join("events_delete_atomic.rdb");
2955 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2956 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2957
2958 rt.execute_query(
2959 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
2960 )
2961 .unwrap();
2962 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2963 .unwrap();
2964
2965 assert_statement_writes_collections_in_one_new_wal_batch(
2966 &rt,
2967 &wal_path,
2968 "DELETE FROM users WHERE id = 1",
2969 "users",
2970 "user_deletes",
2971 );
2972 }
2973
2974 #[test]
2975 fn update_where_id_in_with_hash_index_updates_expected_rows() {
2976 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2977 rt.execute_query("CREATE TABLE users (id INT, score INT)")
2978 .unwrap();
2979 for id in 0..5 {
2980 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
2981 .unwrap();
2982 }
2983 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
2984 .unwrap();
2985
2986 let updated = rt
2987 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
2988 .unwrap();
2989 assert_eq!(updated.affected_rows, 3);
2990
2991 let selected = rt
2992 .execute_query("SELECT id, score FROM users ORDER BY id")
2993 .unwrap();
2994 let scores: Vec<(i64, i64)> = selected
2995 .result
2996 .records
2997 .iter()
2998 .map(|record| {
2999 let id = match record.get("id").unwrap() {
3000 Value::Integer(value) => *value,
3001 other => panic!("expected integer id, got {other:?}"),
3002 };
3003 let score = match record.get("score").unwrap() {
3004 Value::Integer(value) => *value,
3005 other => panic!("expected integer score, got {other:?}"),
3006 };
3007 (id, score)
3008 })
3009 .collect();
3010 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3011 }
3012
3013 #[test]
3020 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3021 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3022 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3023 .unwrap();
3024 for id in 0..5 {
3025 rt.execute_query(&format!(
3026 "INSERT INTO items (id, score) VALUES ({id}, {})",
3027 id * 10
3028 ))
3029 .unwrap();
3030 }
3031 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3032 .unwrap();
3033
3034 let updated_one = rt
3038 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3039 .unwrap();
3040 assert_eq!(updated_one.affected_rows, 1);
3041
3042 let updated_many = rt
3046 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3047 .unwrap();
3048 assert_eq!(updated_many.affected_rows, 2);
3049
3050 let snapshot = rt
3051 .execute_query("SELECT id, score FROM items ORDER BY id")
3052 .unwrap();
3053 let pairs: Vec<(i64, i64)> = snapshot
3054 .result
3055 .records
3056 .iter()
3057 .map(|record| {
3058 let id = match record.get("id").unwrap() {
3059 Value::Integer(value) => *value,
3060 other => panic!("expected integer id, got {other:?}"),
3061 };
3062 let score = match record.get("score").unwrap() {
3063 Value::Integer(value) => *value,
3064 other => panic!("expected integer score, got {other:?}"),
3065 };
3066 (id, score)
3067 })
3068 .collect();
3069 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3070
3071 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3073 assert_eq!(updated_all.affected_rows, 5);
3074 let after = rt
3075 .execute_query("SELECT score FROM items ORDER BY id")
3076 .unwrap();
3077 let scores: Vec<i64> = after
3078 .result
3079 .records
3080 .iter()
3081 .map(|record| match record.get("score").unwrap() {
3082 Value::Integer(value) => *value,
3083 other => panic!("expected integer score, got {other:?}"),
3084 })
3085 .collect();
3086 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3087 }
3088
3089 #[test]
3095 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3096 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3097 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3098 .unwrap();
3099 for id in 0..5 {
3100 rt.execute_query(&format!(
3101 "INSERT INTO items (id, score) VALUES ({id}, {})",
3102 id * 10
3103 ))
3104 .unwrap();
3105 }
3106 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3107 .unwrap();
3108
3109 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3112 assert_eq!(deleted_one.affected_rows, 1);
3113
3114 let deleted_many = rt
3118 .execute_query("DELETE FROM items WHERE score > 25")
3119 .unwrap();
3120 assert_eq!(deleted_many.affected_rows, 2);
3121
3122 let surviving = rt
3123 .execute_query("SELECT id FROM items ORDER BY id")
3124 .unwrap();
3125 let ids: Vec<i64> = surviving
3126 .result
3127 .records
3128 .iter()
3129 .map(|record| match record.get("id").unwrap() {
3130 Value::Integer(value) => *value,
3131 other => panic!("expected integer id, got {other:?}"),
3132 })
3133 .collect();
3134 assert_eq!(ids, vec![0, 1]);
3135
3136 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3138 assert_eq!(deleted_rest.affected_rows, 2);
3139 let empty = rt.execute_query("SELECT id FROM items").unwrap();
3140 assert!(empty.result.records.is_empty());
3141 }
3142
3143 #[test]
3148 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3149 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3150 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3151 .unwrap();
3152
3153 let inserted = rt
3156 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3157 .unwrap();
3158 assert_eq!(inserted.affected_rows, 1);
3159
3160 let update_err = rt
3162 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3163 .unwrap_err();
3164 let msg = format!("{update_err}");
3165 assert!(
3166 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3167 "expected UPDATE rejection message, got: {msg}"
3168 );
3169
3170 let delete_err = rt
3172 .execute_query("DELETE FROM events WHERE id = 1")
3173 .unwrap_err();
3174 let msg = format!("{delete_err}");
3175 assert!(
3176 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3177 "expected DELETE rejection message, got: {msg}"
3178 );
3179
3180 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3183 assert_eq!(surviving.result.records.len(), 1);
3184 }
3185
3186 #[test]
3190 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3191 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3192 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3193 .unwrap();
3194
3195 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3196 .unwrap();
3197 let updated = rt
3198 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3199 .unwrap();
3200 assert_eq!(updated.affected_rows, 1);
3201 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3202 assert_eq!(deleted.affected_rows, 1);
3203 }
3204
3205 #[test]
3206 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3207 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3208 rt.execute_query(
3209 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3210 )
3211 .unwrap();
3212
3213 let inserted = rt
3214 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3215 .unwrap();
3216 assert_eq!(inserted.affected_rows, 1);
3217
3218 let events = queue_payloads(&rt, "audit_log");
3219 assert_eq!(events.len(), 1);
3220 let event = events[0].as_object().expect("event payload object");
3221 assert!(event
3222 .get("event_id")
3223 .and_then(crate::json::Value::as_str)
3224 .is_some_and(|value| !value.is_empty()));
3225 assert_eq!(
3226 event.get("op").and_then(crate::json::Value::as_str),
3227 Some("insert")
3228 );
3229 assert_eq!(
3230 event.get("collection").and_then(crate::json::Value::as_str),
3231 Some("users")
3232 );
3233 assert_eq!(
3234 event.get("id").and_then(crate::json::Value::as_u64),
3235 Some(7)
3236 );
3237 assert!(event
3238 .get("ts")
3239 .and_then(crate::json::Value::as_u64)
3240 .is_some());
3241 assert!(event
3242 .get("lsn")
3243 .and_then(crate::json::Value::as_u64)
3244 .is_some());
3245 assert!(matches!(
3246 event.get("tenant"),
3247 Some(crate::json::Value::Null)
3248 ));
3249 assert!(matches!(
3250 event.get("before"),
3251 Some(crate::json::Value::Null)
3252 ));
3253 let after = event
3254 .get("after")
3255 .and_then(crate::json::Value::as_object)
3256 .expect("after object");
3257 assert_eq!(
3258 after.get("id").and_then(crate::json::Value::as_u64),
3259 Some(7)
3260 );
3261 assert_eq!(
3262 after.get("email").and_then(crate::json::Value::as_str),
3263 Some("a@example.com")
3264 );
3265 }
3266
3267 #[test]
3268 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3269 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3270 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3271 .unwrap();
3272
3273 rt.execute_query(
3274 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
3275 )
3276 .unwrap();
3277
3278 let events = queue_payloads(&rt, "users_events");
3279 assert_eq!(events.len(), 2);
3280 let mut previous_lsn = 0;
3281 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
3282 let object = event.as_object().expect("event payload object");
3283 assert_eq!(
3284 object.get("op").and_then(crate::json::Value::as_str),
3285 Some("insert")
3286 );
3287 assert_eq!(
3288 object.get("id").and_then(crate::json::Value::as_u64),
3289 Some(expected_id)
3290 );
3291 let lsn = object
3292 .get("lsn")
3293 .and_then(crate::json::Value::as_u64)
3294 .expect("event lsn");
3295 assert!(
3296 lsn > previous_lsn,
3297 "event LSNs should increase in row order"
3298 );
3299 previous_lsn = lsn;
3300 let after = object
3301 .get("after")
3302 .and_then(crate::json::Value::as_object)
3303 .expect("after object");
3304 assert_eq!(
3305 after.get("id").and_then(crate::json::Value::as_u64),
3306 Some(expected_id)
3307 );
3308 }
3309 }
3310
3311 #[test]
3312 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
3313 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3314 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
3315 .expect("create table");
3316 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
3318 .expect("create index");
3319 rt.execute_query(
3320 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
3321 (1, 10, 'ready'), (2, 20, 'ready')",
3322 )
3323 .expect("insert rows");
3324
3325 let claim_lock = rt
3326 .inner
3327 .rmw_locks
3328 .lock_for("claim_metric_locked", "__table_claim_update__");
3329 let _guard = claim_lock.lock();
3330 let updated = rt
3331 .execute_query(
3332 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
3333 CLAIM LIMIT 2 ORDER BY rank ASC",
3334 )
3335 .expect("claim skips locked candidates");
3336
3337 assert_eq!(updated.affected_rows, 0);
3338 let snapshot = rt.claim_telemetry_snapshot();
3339 assert_eq!(
3340 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
3341 1
3342 );
3343 assert_eq!(
3344 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
3345 2
3346 );
3347 assert_eq!(
3348 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
3349 0
3350 );
3351 assert_eq!(
3352 snapshot.skipped_locked.len(),
3353 1,
3354 "skipped-lock labels stay bounded to collection/model"
3355 );
3356 }
3357
3358 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3359 let result = rt
3360 .execute_query(&format!("QUEUE PEEK {queue} 10"))
3361 .expect("peek queue");
3362 result
3363 .result
3364 .records
3365 .iter()
3366 .map(
3367 |record| match record.get("payload").expect("payload column") {
3368 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3369 other => panic!("expected JSON queue payload, got {other:?}"),
3370 },
3371 )
3372 .collect()
3373 }
3374
3375 #[test]
3387 fn auto_index_id_fires_on_first_insert() {
3388 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3389 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
3390 .unwrap();
3391
3392 assert!(
3394 rt.index_store_ref()
3395 .find_index_for_column("bench_users", "id")
3396 .is_none(),
3397 "freshly created collection should not have an `id` index"
3398 );
3399
3400 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
3402 .unwrap();
3403
3404 let registered = rt
3406 .index_store_ref()
3407 .find_index_for_column("bench_users", "id")
3408 .expect("auto-index hook should have registered idx_id on first insert");
3409 assert_eq!(registered.name, "idx_id");
3410 assert_eq!(registered.collection, "bench_users");
3411 assert_eq!(registered.columns, vec!["id".to_string()]);
3412 assert!(matches!(
3413 registered.method,
3414 super::super::index_store::IndexMethodKind::Hash
3415 ));
3416
3417 for id in 2..=5 {
3420 rt.execute_query(&format!(
3421 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
3422 id * 10
3423 ))
3424 .unwrap();
3425 }
3426 for id in 1..=5 {
3427 let result = rt
3428 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
3429 .unwrap();
3430 assert_eq!(
3431 result.result.records.len(),
3432 1,
3433 "id={id} should match one row"
3434 );
3435 }
3436
3437 let deleted = rt
3442 .execute_query("DELETE FROM bench_users WHERE id = 3")
3443 .unwrap();
3444 assert_eq!(deleted.affected_rows, 1);
3445 }
3446
3447 #[test]
3452 fn auto_index_id_fires_on_first_bulk_insert() {
3453 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3454 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
3455 .unwrap();
3456
3457 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
3458 .unwrap();
3459
3460 let registered = rt
3461 .index_store_ref()
3462 .find_index_for_column("bench_bulk", "id")
3463 .expect("auto-index hook should fire on first bulk insert");
3464 assert_eq!(registered.name, "idx_id");
3465
3466 for id in 1..=3 {
3468 let result = rt
3469 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
3470 .unwrap();
3471 assert_eq!(result.result.records.len(), 1);
3472 }
3473 }
3474
3475 #[test]
3479 fn auto_index_id_skips_when_no_id_column() {
3480 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3481 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
3482 .unwrap();
3483 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
3484 .unwrap();
3485
3486 assert!(rt
3487 .index_store_ref()
3488 .find_index_for_column("plain", "id")
3489 .is_none());
3490 assert!(rt
3491 .index_store_ref()
3492 .find_index_for_column("plain", "uid")
3493 .is_none());
3494 }
3495
3496 #[test]
3501 fn auto_index_id_skips_when_index_already_exists() {
3502 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3503 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
3504 .unwrap();
3505 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
3507 .unwrap();
3508 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
3509 .unwrap();
3510
3511 let registered = rt
3512 .index_store_ref()
3513 .find_index_for_column("pre", "id")
3514 .expect("user index should still be there");
3515 assert_eq!(
3516 registered.name, "user_idx",
3517 "auto-index hook must not overwrite an existing index"
3518 );
3519 }
3520
3521 #[test]
3525 fn auto_index_id_dropped_with_collection() {
3526 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3527 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
3528 .unwrap();
3529 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
3530 .unwrap();
3531 assert!(rt
3532 .index_store_ref()
3533 .find_index_for_column("ephemeral", "id")
3534 .is_some());
3535
3536 rt.execute_query("DROP TABLE ephemeral").unwrap();
3537
3538 assert!(
3539 rt.index_store_ref()
3540 .find_index_for_column("ephemeral", "id")
3541 .is_none(),
3542 "implicit `idx_id` must be reaped when its collection drops"
3543 );
3544 }
3545
3546 #[test]
3551 fn auto_index_id_disabled_by_config() {
3552 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
3553 let rt = RedDBRuntime::with_options(opts).unwrap();
3554
3555 rt.execute_query("CREATE TABLE off (id INT, score INT)")
3556 .unwrap();
3557 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
3558 .unwrap();
3559
3560 assert!(
3561 rt.index_store_ref()
3562 .find_index_for_column("off", "id")
3563 .is_none(),
3564 "with auto_index_id=false, no implicit index should be created"
3565 );
3566 }
3567
3568 #[test]
3571 fn update_single_row_emits_update_event() {
3572 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3573 rt.execute_query(
3574 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
3575 )
3576 .unwrap();
3577 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3578 .unwrap();
3579
3580 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3581 .unwrap();
3582
3583 let events = queue_payloads(&rt, "audit_log");
3584 assert_eq!(events.len(), 1, "expected exactly 1 update event");
3585 let event = events[0].as_object().expect("event payload object");
3586 assert_eq!(
3587 event.get("op").and_then(crate::json::Value::as_str),
3588 Some("update")
3589 );
3590 assert_eq!(
3591 event.get("collection").and_then(crate::json::Value::as_str),
3592 Some("users")
3593 );
3594 assert!(event
3595 .get("event_id")
3596 .and_then(crate::json::Value::as_str)
3597 .is_some_and(|v| !v.is_empty()));
3598 let before = event
3599 .get("before")
3600 .and_then(crate::json::Value::as_object)
3601 .expect("before must be an object");
3602 let after = event
3603 .get("after")
3604 .and_then(crate::json::Value::as_object)
3605 .expect("after must be an object");
3606 assert_eq!(
3607 before.get("name").and_then(crate::json::Value::as_str),
3608 Some("Alice"),
3609 "before.name should be the old value"
3610 );
3611 assert_eq!(
3612 after.get("name").and_then(crate::json::Value::as_str),
3613 Some("Bob"),
3614 "after.name should be the new value"
3615 );
3616 }
3617
3618 #[test]
3619 fn update_event_only_includes_changed_fields() {
3620 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3621 rt.execute_query(
3622 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
3623 )
3624 .unwrap();
3625 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
3626 .unwrap();
3627
3628 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3629 .unwrap();
3630
3631 let events = queue_payloads(&rt, "evts");
3632 assert_eq!(events.len(), 1);
3633 let event = events[0].as_object().unwrap();
3634 let before = event
3635 .get("before")
3636 .and_then(crate::json::Value::as_object)
3637 .unwrap();
3638 let after = event
3639 .get("after")
3640 .and_then(crate::json::Value::as_object)
3641 .unwrap();
3642 assert!(
3644 before.contains_key("name"),
3645 "before must include changed field"
3646 );
3647 assert!(
3648 after.contains_key("name"),
3649 "after must include changed field"
3650 );
3651 assert!(
3653 !before.contains_key("email"),
3654 "before must not include unchanged email"
3655 );
3656 assert!(
3657 !after.contains_key("email"),
3658 "after must not include unchanged email"
3659 );
3660 }
3661
3662 #[test]
3663 fn multi_row_update_emits_one_event_per_row() {
3664 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3665 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
3666 .unwrap();
3667 rt.execute_query(
3668 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
3669 )
3670 .unwrap();
3671
3672 rt.execute_query("UPDATE items SET status = 'done'")
3673 .unwrap();
3674
3675 let events = queue_payloads(&rt, "evts");
3676 assert_eq!(events.len(), 3, "expected one update event per row");
3677 for event in &events {
3678 let obj = event.as_object().unwrap();
3679 assert_eq!(
3680 obj.get("op").and_then(crate::json::Value::as_str),
3681 Some("update")
3682 );
3683 }
3684 }
3685
3686 #[test]
3687 fn delete_single_row_emits_delete_event() {
3688 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3689 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
3690 .unwrap();
3691 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
3692 .unwrap();
3693
3694 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
3695
3696 let events = queue_payloads(&rt, "del_log");
3697 assert_eq!(events.len(), 1);
3698 let event = events[0].as_object().expect("event payload object");
3699 assert_eq!(
3700 event.get("op").and_then(crate::json::Value::as_str),
3701 Some("delete")
3702 );
3703 assert_eq!(
3704 event.get("collection").and_then(crate::json::Value::as_str),
3705 Some("users")
3706 );
3707 assert!(event
3708 .get("event_id")
3709 .and_then(crate::json::Value::as_str)
3710 .is_some_and(|v| !v.is_empty()));
3711 let before = event
3712 .get("before")
3713 .and_then(crate::json::Value::as_object)
3714 .expect("before must be an object for delete");
3715 assert_eq!(
3716 before.get("id").and_then(crate::json::Value::as_u64),
3717 Some(42)
3718 );
3719 assert_eq!(
3720 before.get("name").and_then(crate::json::Value::as_str),
3721 Some("Alice")
3722 );
3723 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
3724 }
3725
3726 #[test]
3727 fn multi_row_delete_emits_one_event_per_row() {
3728 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3729 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
3730 .unwrap();
3731 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
3732 .unwrap();
3733
3734 rt.execute_query("DELETE FROM items").unwrap();
3735
3736 let events = queue_payloads(&rt, "del_log");
3737 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
3738 for event in &events {
3739 let obj = event.as_object().unwrap();
3740 assert_eq!(
3741 obj.get("op").and_then(crate::json::Value::as_str),
3742 Some("delete")
3743 );
3744 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
3745 }
3746 }
3747
3748 #[test]
3749 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
3750 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3751 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
3752 .unwrap();
3753
3754 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3755 .unwrap();
3756 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3757
3758 let events = queue_payloads(&rt, "evts");
3759 assert!(
3760 events.is_empty(),
3761 "UPDATE-only filter must not emit INSERT or DELETE events"
3762 );
3763 }
3764
3765 #[test]
3768 fn suppress_events_on_insert_emits_no_events() {
3769 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3770 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3771 .unwrap();
3772
3773 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3774 .unwrap();
3775
3776 let events = queue_payloads(&rt, "evts");
3777 assert!(
3778 events.is_empty(),
3779 "SUPPRESS EVENTS must prevent INSERT events"
3780 );
3781 }
3782
3783 #[test]
3784 fn suppress_events_on_update_emits_no_events() {
3785 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3786 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3787 .unwrap();
3788 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3789 .unwrap();
3790 let _ = queue_payloads(&rt, "evts");
3792 rt.execute_query("QUEUE PURGE evts").unwrap();
3794
3795 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
3796 .unwrap();
3797
3798 let events = queue_payloads(&rt, "evts");
3799 assert!(
3800 events.is_empty(),
3801 "SUPPRESS EVENTS must prevent UPDATE events"
3802 );
3803 }
3804
3805 #[test]
3806 fn suppress_events_on_delete_emits_no_events() {
3807 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3808 rt.execute_query(
3809 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
3810 )
3811 .unwrap();
3812 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3813 .unwrap();
3814
3815 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
3816 .unwrap();
3817
3818 let events = queue_payloads(&rt, "evts");
3819 assert!(
3820 events.is_empty(),
3821 "SUPPRESS EVENTS must prevent DELETE events"
3822 );
3823 }
3824
3825 #[test]
3826 fn normal_insert_after_suppress_still_emits() {
3827 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3828 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3829 .unwrap();
3830
3831 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3832 .unwrap();
3833 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
3834 .unwrap();
3835
3836 let events = queue_payloads(&rt, "evts");
3837 assert_eq!(
3838 events.len(),
3839 1,
3840 "only the non-suppressed INSERT should emit"
3841 );
3842 assert_eq!(
3843 events[0].get("id").and_then(crate::json::Value::as_u64),
3844 Some(2)
3845 );
3846 }
3847}