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
57pub(super) const 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 touches_unique_index: bool,
79}
80
81#[derive(Default)]
82pub(super) struct MaterializedUpdateAssignments {
83 pub(super) dynamic_field_assignments: Vec<(String, Value)>,
84 pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
85}
86
87fn resolve_excluded_assignment(
88 expr: &Expr,
89 excluded_fields: &[(String, Value)],
90) -> RedDBResult<Expr> {
91 match expr {
92 Expr::Column {
93 field: FieldRef::TableColumn { table, column },
94 span,
95 } if table.eq_ignore_ascii_case("excluded") => {
96 let value = excluded_fields
97 .iter()
98 .find(|(name, _)| name.eq_ignore_ascii_case(column))
99 .map(|(_, value)| value.clone())
100 .ok_or_else(|| {
101 RedDBError::Query(format!(
102 "ON CONFLICT EXCLUDED references unknown insert column '{column}'"
103 ))
104 })?;
105 Ok(Expr::Literal { value, span: *span })
106 }
107 Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
108 op: *op,
109 lhs: Box::new(resolve_excluded_assignment(lhs, excluded_fields)?),
110 rhs: Box::new(resolve_excluded_assignment(rhs, excluded_fields)?),
111 span: *span,
112 }),
113 _ => Ok(expr.clone()),
114 }
115}
116
117fn conflict_columns_match(index_columns: &[String], target_columns: &[String]) -> bool {
118 index_columns.len() == target_columns.len()
119 && index_columns.iter().all(|index_column| {
120 target_columns
121 .iter()
122 .any(|target_column| target_column.eq_ignore_ascii_case(index_column))
123 })
124}
125
126fn conflict_index_key(fields: &[(String, Value)], columns: &[String]) -> Option<Vec<Value>> {
127 columns
128 .iter()
129 .map(|column| {
130 super::index_store::index_field_value(fields, column)
131 .filter(|value| !matches!(value.as_ref(), Value::Null))
132 .map(|value| value.into_owned())
133 })
134 .collect()
135}
136
137fn document_on_conflict_fields(
138 stored_fields: &[(String, Value)],
139 body: &crate::json::Value,
140) -> RedDBResult<Vec<(String, Value)>> {
141 let mut fields = stored_fields.to_vec();
142 if let crate::json::Value::Object(object) = body {
143 for (name, value) in object {
144 fields.push((
145 name.clone(),
146 crate::application::entity::json_to_storage_value(value)?,
147 ));
148 }
149 }
150 Ok(fields)
151}
152
153impl RedDBRuntime {
154 fn conflict_unique_indexes(
155 &self,
156 collection: &str,
157 target: Option<&[String]>,
158 ) -> RedDBResult<Vec<super::index_store::RegisteredIndex>> {
159 let mut indexes = self
160 .index_store_ref()
161 .list_indices(collection)
162 .into_iter()
163 .filter(|index| index.unique)
164 .collect::<Vec<_>>();
165 if let Some(target) = target {
166 indexes.retain(|index| conflict_columns_match(&index.columns, target));
167 if indexes.is_empty() {
168 return Err(RedDBError::Query(format!(
169 "no unique index on collection '{}' matches ON CONFLICT ({})",
170 collection,
171 target.join(", ")
172 )));
173 }
174 }
175 Ok(indexes)
176 }
177
178 fn unique_index_conflict_id(
179 &self,
180 collection: &str,
181 fields: &[(String, Value)],
182 target: Option<&[String]>,
183 ) -> RedDBResult<Option<EntityId>> {
184 let indexes = self.conflict_unique_indexes(collection, target)?;
185 let Some(manager) = self.inner.db.store().get_collection(collection) else {
186 return Ok(None);
187 };
188 for index in indexes {
189 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
190 continue;
191 };
192 for entity in manager.query_all(|_| true) {
193 let Some(record) = runtime_any_record_from_entity_ref(&entity) else {
194 continue;
195 };
196 let existing_fields = record
197 .iter_fields()
198 .map(|(name, value)| (name.as_ref().to_string(), value.clone()))
199 .collect::<Vec<_>>();
200 if self.snapshot_manager().is_aborted(entity.xmin) {
201 self.index_store_ref()
202 .index_entity_delete(collection, entity.id, &existing_fields)
203 .map_err(RedDBError::Internal)?;
204 continue;
205 }
206 if conflict_index_key(&existing_fields, &index.columns)
207 .is_some_and(|existing_key| existing_key == proposed_key)
208 {
209 let conflicting_xid = entity.xmin;
210 if self.snapshot_manager().is_active(conflicting_xid)
211 && !self.own_transaction_xids().contains(&conflicting_xid)
212 {
213 return Err(RedDBError::Query(format!(
214 "serialization conflict: ON CONFLICT key in '{collection}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves"
215 )));
216 }
217 return Ok(Some(entity.id));
218 }
219 }
220 }
221 Ok(None)
222 }
223
224 fn on_conflict_unique_index_conflict_id(
225 &self,
226 collection: &str,
227 fields: &[(String, Value)],
228 target: Option<&[String]>,
229 ) -> RedDBResult<Option<EntityId>> {
230 let selected = self.unique_index_conflict_id(collection, fields, target)?;
231 if selected.is_some() || target.is_none() {
232 return Ok(selected);
233 }
234 if self
235 .unique_index_conflict_id(collection, fields, None)?
236 .is_some()
237 {
238 return Err(RedDBError::Query(format!(
239 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
240 target.unwrap_or_default().join(", ")
241 )));
242 }
243 Ok(None)
244 }
245
246 fn has_unique_index_conflict_with_fields(
247 &self,
248 collection: &str,
249 fields: &[(String, Value)],
250 existing_fields: &[Vec<(String, Value)>],
251 target: Option<&[String]>,
252 ) -> RedDBResult<bool> {
253 for index in self.conflict_unique_indexes(collection, target)? {
254 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
255 continue;
256 };
257 if existing_fields.iter().any(|existing| {
258 conflict_index_key(existing, &index.columns)
259 .is_some_and(|existing_key| existing_key == proposed_key)
260 }) {
261 return Ok(true);
262 }
263 }
264 Ok(false)
265 }
266
267 fn has_on_conflict_unique_index_conflict_with_fields(
268 &self,
269 collection: &str,
270 fields: &[(String, Value)],
271 existing_fields: &[Vec<(String, Value)>],
272 target: Option<&[String]>,
273 ) -> RedDBResult<bool> {
274 let selected = self.has_unique_index_conflict_with_fields(
275 collection,
276 fields,
277 existing_fields,
278 target,
279 )?;
280 if selected || target.is_none() {
281 return Ok(selected);
282 }
283 if self.has_unique_index_conflict_with_fields(collection, fields, existing_fields, None)? {
284 return Err(RedDBError::Query(format!(
285 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
286 target.unwrap_or_default().join(", ")
287 )));
288 }
289 Ok(false)
290 }
291
292 fn execute_unique_index_conflict_update(
293 &self,
294 raw_query: &str,
295 insert: &InsertQuery,
296 conflict_id: EntityId,
297 target: UpdateTarget,
298 excluded_fields: &[(String, Value)],
299 assignments: &[(String, Expr)],
300 ) -> RedDBResult<RuntimeQueryResult> {
301 let assignment_exprs = assignments
302 .iter()
303 .map(|(column, expr)| {
304 Ok((
305 column.clone(),
306 resolve_excluded_assignment(expr, excluded_fields)?,
307 ))
308 })
309 .collect::<RedDBResult<Vec<_>>>()?;
310 let literal_assignments = assignment_exprs
311 .iter()
312 .filter_map(|(column, expr)| {
313 fold_expr_to_value(expr.clone())
314 .ok()
315 .map(|value| (column.clone(), value))
316 })
317 .collect();
318 let update = UpdateQuery {
319 table: insert.table.clone(),
320 target,
321 compound_assignment_ops: vec![None; assignment_exprs.len()],
322 assignment_exprs,
323 assignments: literal_assignments,
324 where_expr: None,
325 filter: Some(Filter::Compare {
326 field: FieldRef::column("", "rid"),
327 op: CompareOp::Eq,
328 value: Value::UnsignedInteger(conflict_id.raw()),
329 }),
330 ttl_ms: None,
331 expires_at_ms: None,
332 with_metadata: Vec::new(),
333 returning: insert.returning.clone(),
334 claim_limit: None,
335 claim_exact: false,
336 order_by: Vec::new(),
337 limit: None,
338 suppress_events: insert.suppress_events,
339 };
340 self.execute_update(raw_query, &update)
341 }
342
343 fn infer_unmarked_document_insert(
362 &self,
363 query: &InsertQuery,
364 ) -> RedDBResult<Option<InsertQuery>> {
365 if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
366 return Ok(None);
367 }
368 match self
369 .db()
370 .collection_contract_arc(&query.table)
371 .map(|contract| contract.declared_model)
372 {
373 Some(crate::catalog::CollectionModel::Document) => {
374 let mut rewritten = query.clone();
375 rewritten.entity_type = InsertEntityType::Document;
376 rewritten.columns = vec!["body".to_string()];
377 Ok(Some(rewritten))
378 }
379 Some(other) => Err(RedDBError::InvalidOperation(format!(
380 "collection '{table}' is declared as '{model}'; the bare \
381 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
382 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
383 to insert into a {model} collection",
384 table = query.table,
385 model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
386 ))),
387 None => Err(RedDBError::InvalidOperation(format!(
388 "collection '{table}' does not exist and the INSERT carries no model marker; \
389 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
390 (idempotent), or run `CREATE DOCUMENT {table}` first",
391 table = query.table,
392 ))),
393 }
394 }
395
396 fn resolve_unmarked_update_target(
409 &self,
410 query: &UpdateQuery,
411 ) -> RedDBResult<Option<UpdateQuery>> {
412 if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
415 ensure_update_dotted_targets_allowed(query, false)?;
416 return Ok(None);
417 }
418 let declared_model = self
419 .db()
420 .collection_contract_arc(&query.table)
421 .map(|contract| contract.declared_model);
422 let is_document = matches!(
423 declared_model,
424 Some(crate::catalog::CollectionModel::Document)
425 );
426 ensure_update_dotted_targets_allowed(query, is_document)?;
427 let inferred = match declared_model {
428 Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
429 Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
430 _ => return Ok(None),
431 };
432 let mut rewritten = query.clone();
433 rewritten.target = inferred;
434 Ok(Some(rewritten))
435 }
436
437 pub fn execute_insert(
442 &self,
443 raw_query: &str,
444 query: &InsertQuery,
445 ) -> RedDBResult<RuntimeQueryResult> {
446 self.with_graph_insert_for_dml(query, || self.execute_insert_inner(raw_query, query))
447 }
448
449 fn execute_insert_inner(
450 &self,
451 raw_query: &str,
452 query: &InsertQuery,
453 ) -> RedDBResult<RuntimeQueryResult> {
454 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
455 let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
456 let branch_store = self.db().store();
457 let _branch_publication = (self.vcs_is_versioned(&query.table)?
458 || self.has_graph_contract(&query.table))
459 .then(|| branch_store.vault_publication_lock.lock());
460 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
461 self,
462 &query.table,
463 );
464 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
465 crate::runtime::collection_contract::CollectionContractGate::check(
471 self,
472 &query.table,
473 crate::runtime::collection_contract::MutationKind::Insert,
474 )?;
475 let inferred_owned;
484 let query = match self.infer_unmarked_document_insert(query)? {
485 Some(rewritten) => {
486 inferred_owned = rewritten;
487 &inferred_owned
488 }
489 None => query,
490 };
491 let augmented_owned;
499 let query = match self.maybe_inject_tenant_column(query)? {
500 Some(new_q) => {
501 augmented_owned = new_q;
502 &augmented_owned
503 }
504 None => query,
505 };
506 if query.on_conflict.is_some()
507 && !matches!(
508 query.entity_type,
509 InsertEntityType::Row
510 | InsertEntityType::Document
511 | InsertEntityType::Node
512 | InsertEntityType::Edge
513 )
514 {
515 return Err(RedDBError::InvalidOperation(
516 "ON CONFLICT is supported for table rows, documents, graph nodes, and graph edges"
517 .to_string(),
518 ));
519 }
520 self.check_insert_column_policy(query)?;
521 let resolved_auto_embed = if let Some(ref embed_config) = query.auto_embed {
522 let provider =
526 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
527 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
531 if matches!(provider, crate::ai::AiProvider::Local) {
532 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
533 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
541 if model_name.is_empty() {
542 return Err(RedDBError::Query(
543 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
544 the local provider does not have an implicit default model"
545 .to_string(),
546 ));
547 }
548 crate::runtime::ai::local_embedding::preflight_local_embedding(
549 &self.inner.db,
550 model_name,
551 )?;
552 }
553 let connection = if matches!(provider, crate::ai::AiProvider::Local) {
554 None
555 } else {
556 Some(crate::ai::resolve_connection_from_runtime(
557 &provider, None, self,
558 )?)
559 };
560 let model = crate::ai::resolve_embeddings_model_from_runtime(
561 self,
562 &provider,
563 embed_config.model.as_deref(),
564 );
565 Some((provider, model, connection, &embed_config.fields))
566 } else {
567 None
568 };
569
570 let mut inserted_count: u64 = 0;
571 let effective_rows =
572 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
573 if effective_rows.len() > 1
574 && matches!(
575 query.on_conflict.as_ref().map(|clause| &clause.action),
576 Some(OnConflictAction::DoUpdate { .. })
577 )
578 {
579 return Err(RedDBError::InvalidOperation(
580 "multi-row ON CONFLICT DO UPDATE is not supported atomically; execute one proposed row per statement"
581 .to_string(),
582 ));
583 }
584
585 let store = self.inner.db.store();
587 let _ = store.get_or_create_collection(&query.table);
588 let contract = self.db().collection_contract_arc(&query.table);
589 let declared_model = contract.as_ref().map(|contract| contract.declared_model);
590 let secret_columns: Vec<bool> = query
591 .columns
592 .iter()
593 .map(|column| {
594 contract.as_ref().is_some_and(|contract| {
595 contract.declared_columns.iter().any(|declared| {
596 declared.name == *column
597 && declared.data_type.eq_ignore_ascii_case("secret")
598 })
599 })
600 })
601 .collect();
602 if query.on_conflict.is_some()
603 && matches!(
604 declared_model,
605 Some(crate::catalog::CollectionModel::TimeSeries)
606 )
607 {
608 return Err(RedDBError::InvalidOperation(
609 "ON CONFLICT is not supported for time-series collections".to_string(),
610 ));
611 }
612
613 let conflict_rmw_lock = query.on_conflict.as_ref().map(|_| {
618 self.inner
619 .rmw_locks
620 .lock_for(&query.table, "__collection_on_conflict_insert__")
621 });
622 let _conflict_rmw_guard = conflict_rmw_lock.as_ref().map(|lock| lock.lock());
623
624 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
625 if query.returning.is_some() {
626 Some(Vec::with_capacity(effective_rows.len()))
627 } else {
628 None
629 };
630 let mut returning_result: Option<UnifiedResult> = None;
631 let mut conflict_returning_records: Vec<UnifiedRecord> = Vec::new();
632 let mut auto_embed_ids = Vec::new();
633
634 if matches!(query.entity_type, InsertEntityType::Row)
635 && !matches!(
636 declared_model,
637 Some(crate::catalog::CollectionModel::TimeSeries)
638 )
639 {
640 let chain_mode = crate::runtime::blockchain_kind::try_is_chain(&store, &query.table)?;
651 let _chain_lock_arc: Option<Arc<super::RmwRowLock>> = if chain_mode {
652 Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
653 } else {
654 None
655 };
656 let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
657
658 if chain_mode && self.is_chain_integrity_broken(&query.table)? {
661 return Err(RedDBError::InvalidOperation(format!(
662 "ChainIntegrityBroken: collection '{}' is locked until \
663 POST /collections/{}/clear-integrity-flag is called by an admin",
664 query.table, query.table
665 )));
666 }
667
668 let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
672 if chain_mode {
673 let mut cache = self.inner.chain_tip_cache.lock();
674 if let Some(existing) = cache.get(&query.table) {
675 Some(existing.clone())
676 } else if let Some(scanned) =
677 crate::runtime::blockchain_kind::try_chain_tip_full(&store, &query.table)?
678 {
679 cache.insert(query.table.clone(), scanned.clone());
680 Some(scanned)
681 } else {
682 None
683 }
684 } else {
685 None
686 };
687
688 let mut rows = Vec::with_capacity(effective_rows.len());
689 let mut accepted_row_fields: Vec<Vec<(String, Value)>> = Vec::new();
690 for row_values in &effective_rows {
691 if row_values.len() != query.columns.len() {
692 return Err(RedDBError::Query(format!(
693 "INSERT column count ({}) does not match value count ({})",
694 query.columns.len(),
695 row_values.len()
696 )));
697 }
698 let (mut fields, mut metadata) =
699 split_insert_metadata(self, &secret_columns, &query.columns, row_values)?;
700 if query
701 .on_conflict
702 .as_ref()
703 .is_some_and(|clause| clause.target.is_none())
704 {
705 fields = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
706 self,
707 &self.db(),
708 &query.table,
709 )
710 .normalize_insert_fields(fields)?;
711 }
712 if let Some(clause) = &query.on_conflict {
713 let conflict_id = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
714 self,
715 &self.db(),
716 &query.table,
717 )
718 .row_uniqueness_conflict_id(&fields, clause.target.as_deref())?
719 .filter(|conflict_id| {
720 store.get(&query.table, *conflict_id).is_some_and(|entity| {
721 !self.snapshot_manager().is_aborted(entity.xmin)
722 })
723 });
724 if let Some(conflict_id) = conflict_id {
725 if let Some(conflicting) = store.get(&query.table, conflict_id) {
726 let conflicting_xid = conflicting.xmin;
727 if self.snapshot_manager().is_active(conflicting_xid)
728 && !self.own_transaction_xids().contains(&conflicting_xid)
729 {
730 return Err(RedDBError::Query(format!(
731 "serialization conflict: ON CONFLICT key in '{}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves",
732 query.table
733 )));
734 }
735 }
736 match &clause.action {
737 OnConflictAction::DoNothing => continue,
738 OnConflictAction::DoUpdate { assignments } => {
739 let assignment_exprs = assignments
740 .iter()
741 .map(|(column, expr)| {
742 Ok((
743 column.clone(),
744 resolve_excluded_assignment(expr, &fields)?,
745 ))
746 })
747 .collect::<RedDBResult<Vec<_>>>()?;
748 let literal_assignments = assignment_exprs
749 .iter()
750 .filter_map(|(column, expr)| {
751 fold_expr_to_value(expr.clone())
752 .ok()
753 .map(|value| (column.clone(), value))
754 })
755 .collect();
756 let update = UpdateQuery {
757 table: query.table.clone(),
758 target: UpdateTarget::Rows,
759 compound_assignment_ops: vec![None; assignment_exprs.len()],
760 assignment_exprs,
761 assignments: literal_assignments,
762 where_expr: None,
763 filter: Some(Filter::Compare {
764 field: FieldRef::column("", "rid"),
765 op: CompareOp::Eq,
766 value: Value::UnsignedInteger(
769 store
770 .get(&query.table, conflict_id)
771 .map(|entity| entity.logical_id().raw())
772 .unwrap_or(conflict_id.raw()),
773 ),
774 }),
775 ttl_ms: None,
776 expires_at_ms: None,
777 with_metadata: Vec::new(),
778 returning: query.returning.clone(),
779 claim_limit: None,
780 claim_exact: false,
781 order_by: Vec::new(),
782 limit: None,
783 suppress_events: query.suppress_events,
784 };
785 let updated = self.execute_update(raw_query, &update)?;
786 inserted_count += updated.affected_rows;
787 conflict_returning_records.extend(updated.result.records);
788 continue;
789 }
790 }
791 }
792 if matches!(&clause.action, OnConflictAction::DoNothing)
793 && crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
794 self,
795 &self.db(),
796 &query.table,
797 )
798 .has_row_uniqueness_conflict_with_rows(
799 &fields,
800 &accepted_row_fields,
801 clause.target.as_deref(),
802 )?
803 {
804 continue;
805 }
806 }
807 if chain_mode {
808 use crate::runtime::blockchain_kind::{
809 chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
810 COL_TIMESTAMP, RESERVED_COLUMNS,
811 };
812 let supplied_height = fields
813 .iter()
814 .find(|(k, _)| k == COL_BLOCK_HEIGHT)
815 .map(|(_, v)| v.clone());
816 let supplied_prev = fields
817 .iter()
818 .find(|(k, _)| k == COL_PREV_HASH)
819 .map(|(_, v)| v.clone());
820 let supplied_ts = fields
821 .iter()
822 .find(|(k, _)| k == COL_TIMESTAMP)
823 .map(|(_, v)| v.clone());
824 let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
825 let user_supplied_any = supplied_height.is_some()
826 || supplied_prev.is_some()
827 || supplied_ts.is_some()
828 || supplied_hash;
829
830 fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
831 let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
832
833 let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
834 Some(t) => (t.hash, t.height + 1),
835 None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
836 };
837 let server_now = crate::runtime::blockchain_kind::now_ms();
838
839 let (use_prev, use_height, use_ts) = if user_supplied_any {
840 if supplied_hash {
843 return Err(chain_conflict_error(
844 tip_next_height.saturating_sub(1),
845 tip_prev_hash,
846 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
847 server_now,
848 "hash column is engine-computed and cannot be supplied",
849 ));
850 }
851 let caller_prev = match &supplied_prev {
852 Some(Value::Blob(b)) if b.len() == 32 => {
853 let mut a = [0u8; 32];
854 a.copy_from_slice(b);
855 a
856 }
857 Some(Value::Text(s)) if s.len() == 64 => {
858 let mut a = [0u8; 32];
862 let mut ok = true;
863 for (i, slot) in a.iter_mut().enumerate() {
864 let pair = &s.as_ref()[i * 2..i * 2 + 2];
865 match u8::from_str_radix(pair, 16) {
866 Ok(byte) => *slot = byte,
867 Err(_) => {
868 ok = false;
869 break;
870 }
871 }
872 }
873 if !ok {
874 return Err(chain_conflict_error(
875 tip_next_height.saturating_sub(1),
876 tip_prev_hash,
877 chain_tip_full
878 .as_ref()
879 .map(|t| t.timestamp_ms)
880 .unwrap_or(0),
881 server_now,
882 "prev_hash is not valid hex",
883 ));
884 }
885 a
886 }
887 _ => {
888 return Err(chain_conflict_error(
889 tip_next_height.saturating_sub(1),
890 tip_prev_hash,
891 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
892 server_now,
893 "prev_hash missing or not a 32-byte Blob",
894 ));
895 }
896 };
897 if caller_prev != tip_prev_hash {
898 return Err(chain_conflict_error(
899 tip_next_height.saturating_sub(1),
900 tip_prev_hash,
901 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
902 server_now,
903 "prev_hash does not match current tip",
904 ));
905 }
906 let caller_height = match &supplied_height {
907 Some(Value::UnsignedInteger(v)) => *v,
908 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
909 _ => {
910 return Err(chain_conflict_error(
911 tip_next_height.saturating_sub(1),
912 tip_prev_hash,
913 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
914 server_now,
915 "block_height missing or not an unsigned integer",
916 ));
917 }
918 };
919 if caller_height != tip_next_height {
920 return Err(chain_conflict_error(
921 tip_next_height.saturating_sub(1),
922 tip_prev_hash,
923 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
924 server_now,
925 "block_height does not match tip+1",
926 ));
927 }
928 let caller_ts = match &supplied_ts {
929 Some(Value::UnsignedInteger(v)) => *v,
930 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
931 _ => {
932 return Err(chain_conflict_error(
933 tip_next_height.saturating_sub(1),
934 tip_prev_hash,
935 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
936 server_now,
937 "timestamp missing or not an unsigned integer",
938 ));
939 }
940 };
941 let drift = (caller_ts as i128) - (server_now as i128);
942 if drift.abs() > 60_000 {
943 return Err(chain_conflict_error(
944 tip_next_height.saturating_sub(1),
945 tip_prev_hash,
946 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
947 server_now,
948 "timestamp outside ±60s of server_time",
949 ));
950 }
951 (caller_prev, caller_height, caller_ts)
952 } else {
953 (tip_prev_hash, tip_next_height, server_now)
954 };
955
956 let (reserved, new_hash) =
957 crate::runtime::blockchain_kind::make_block_reserved_fields(
958 use_prev, use_height, use_ts, &payload,
959 );
960 fields.extend(reserved);
961 chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
962 height: use_height,
963 hash: new_hash,
964 timestamp_ms: use_ts,
965 });
966 }
967 if crate::runtime::signed_writes_kind::try_is_signed(&store, &query.table)? {
979 let (pk_col, sig_col, residual) =
980 crate::runtime::signed_writes_kind::split_signature_fields(fields);
981 let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
982 let reg =
983 crate::runtime::signed_writes_kind::try_registry(&store, &query.table)?;
984 crate::runtime::signed_writes_kind::verify_row(
985 ®,
986 pk_col.as_ref().map(|c| c.bytes.as_slice()),
987 sig_col.as_ref().map(|c| c.bytes.as_slice()),
988 &payload,
989 )
990 .map_err(crate::runtime::signed_writes_kind::map_error)?;
991 fields = residual;
992 if let Some(col) = pk_col {
997 fields.push((
998 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
999 col.raw_value,
1000 ));
1001 }
1002 if let Some(col) = sig_col {
1003 fields.push((
1004 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
1005 col.raw_value,
1006 ));
1007 }
1008 }
1009 merge_with_clauses(
1010 &mut metadata,
1011 query.ttl_ms,
1012 query.expires_at_ms,
1013 &query.with_metadata,
1014 );
1015 if let Some(snaps) = returning_snapshots.as_mut() {
1016 snaps.push(fields.clone());
1017 }
1018 accepted_row_fields.push(fields.clone());
1019 rows.push(CreateRowInput {
1020 collection: query.table.clone(),
1021 fields,
1022 metadata,
1023 node_links: Vec::new(),
1024 vector_links: Vec::new(),
1025 });
1026 }
1027 let batch = CreateRowsBatchInput {
1028 collection: query.table.clone(),
1029 rows,
1030 suppress_events: query.suppress_events,
1031 };
1032 let outputs = if query
1033 .on_conflict
1034 .as_ref()
1035 .is_some_and(|clause| clause.target.is_none())
1036 {
1037 self.create_rows_batch_prevalidated_with_outputs(batch)?
1038 } else {
1039 self.create_rows_batch(batch)?
1040 };
1041 inserted_count += outputs.len() as u64;
1042 if query.auto_embed.is_some() {
1043 auto_embed_ids.extend(outputs.iter().map(|output| output.id));
1044 }
1045
1046 if chain_mode {
1050 if let Some(new_tip) = chain_tip_full.as_ref() {
1051 self.inner
1052 .chain_tip_cache
1053 .lock()
1054 .insert(query.table.clone(), new_tip.clone());
1055 }
1056 }
1057
1058 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1066 let time_col = &spec.time_column;
1067 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1069 for row in &effective_rows {
1070 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1071 if *n >= 0 {
1072 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1073 }
1074 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1075 let _ = self.inner.db.hypertables().route(&query.table, *n);
1076 }
1077 }
1078 }
1079 }
1080
1081 if let (Some(items), Some(snaps)) =
1082 (query.returning.as_ref(), returning_snapshots.take())
1083 {
1084 let snaps = row_insert_returning_snapshots(&outputs, snaps);
1085 let mut result = build_returning_result(items, &snaps, Some(&outputs));
1086 result
1087 .records
1088 .splice(0..0, conflict_returning_records.drain(..));
1089 returning_result = Some(result);
1090 }
1091 } else {
1092 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1099 Vec::with_capacity(effective_rows.len());
1100 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1101 {
1102 Vec::with_capacity(effective_rows.len())
1103 } else {
1104 Vec::new()
1105 };
1106 if matches!(
1107 query.entity_type,
1108 InsertEntityType::Node | InsertEntityType::Edge
1109 ) {
1110 enum PreparedGraphInsert {
1111 Node {
1112 fields: Vec<(String, Value)>,
1113 input: CreateNodeInput,
1114 },
1115 Edge {
1116 fields: Vec<(String, Value)>,
1117 input: CreateEdgeInput,
1118 },
1119 }
1120
1121 let mut prepared = Vec::with_capacity(effective_rows.len());
1122 let mut accepted_graph_fields: Vec<Vec<(String, Value)>> = Vec::new();
1123 for row_values in &effective_rows {
1124 if row_values.len() != query.columns.len() {
1125 return Err(RedDBError::Query(format!(
1126 "INSERT column count ({}) does not match value count ({})",
1127 query.columns.len(),
1128 row_values.len()
1129 )));
1130 }
1131
1132 match query.entity_type {
1133 InsertEntityType::Node => {
1134 let (node_values, mut metadata) = split_insert_metadata(
1135 self,
1136 &secret_columns,
1137 &query.columns,
1138 row_values,
1139 )?;
1140 if let Some(clause) = &query.on_conflict {
1141 if let Some(conflict_id) = self
1142 .on_conflict_unique_index_conflict_id(
1143 &query.table,
1144 &node_values,
1145 clause.target.as_deref(),
1146 )?
1147 {
1148 match &clause.action {
1149 OnConflictAction::DoNothing => continue,
1150 OnConflictAction::DoUpdate { assignments } => {
1151 let updated = self
1152 .execute_unique_index_conflict_update(
1153 raw_query,
1154 query,
1155 conflict_id,
1156 UpdateTarget::Nodes,
1157 &node_values,
1158 assignments,
1159 )?;
1160 inserted_count += updated.affected_rows;
1161 conflict_returning_records
1162 .extend(updated.result.records);
1163 continue;
1164 }
1165 }
1166 }
1167 if matches!(&clause.action, OnConflictAction::DoNothing)
1168 && self.has_on_conflict_unique_index_conflict_with_fields(
1169 &query.table,
1170 &node_values,
1171 &accepted_graph_fields,
1172 clause.target.as_deref(),
1173 )?
1174 {
1175 continue;
1176 }
1177 }
1178 merge_with_clauses(
1179 &mut metadata,
1180 query.ttl_ms,
1181 query.expires_at_ms,
1182 &query.with_metadata,
1183 );
1184 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1185 apply_collection_default_ttl_metadata(
1186 self,
1187 &query.table,
1188 &mut metadata,
1189 );
1190 let (columns, values) = pairwise_columns_values(&node_values);
1191 let label = find_column_value_string(&columns, &values, "label")?;
1192 let node_type = columns
1193 .iter()
1194 .position(|name| name.eq_ignore_ascii_case("node_type"))
1195 .map(|index| match &values[index] {
1196 Value::Text(value) => Ok(value.to_string()),
1197 _ => Err(RedDBError::Query(
1198 "node_type must be one text value".into(),
1199 )),
1200 })
1201 .transpose()?;
1202 let properties = extract_remaining_properties(
1203 &columns,
1204 &values,
1205 &["label", "node_type"],
1206 );
1207 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1208 properties.iter().map(|(key, _)| key.as_str()),
1209 &format!("node '{}'", query.table),
1210 )?;
1211 accepted_graph_fields.push(node_values.clone());
1212 prepared.push(PreparedGraphInsert::Node {
1213 fields: node_values,
1214 input: CreateNodeInput {
1215 collection: query.table.clone(),
1216 label,
1217 node_type,
1218 properties,
1219 metadata,
1220 embeddings: Vec::new(),
1221 table_links: Vec::new(),
1222 node_links: Vec::new(),
1223 },
1224 });
1225 }
1226 InsertEntityType::Edge => {
1227 let (edge_values, mut metadata) = split_insert_metadata(
1228 self,
1229 &secret_columns,
1230 &query.columns,
1231 row_values,
1232 )?;
1233 if let Some(clause) = &query.on_conflict {
1234 if let Some(conflict_id) = self
1235 .on_conflict_unique_index_conflict_id(
1236 &query.table,
1237 &edge_values,
1238 clause.target.as_deref(),
1239 )?
1240 {
1241 match &clause.action {
1242 OnConflictAction::DoNothing => continue,
1243 OnConflictAction::DoUpdate { assignments } => {
1244 let updated = self
1245 .execute_unique_index_conflict_update(
1246 raw_query,
1247 query,
1248 conflict_id,
1249 UpdateTarget::Edges,
1250 &edge_values,
1251 assignments,
1252 )?;
1253 inserted_count += updated.affected_rows;
1254 conflict_returning_records
1255 .extend(updated.result.records);
1256 continue;
1257 }
1258 }
1259 }
1260 if matches!(&clause.action, OnConflictAction::DoNothing)
1261 && self.has_on_conflict_unique_index_conflict_with_fields(
1262 &query.table,
1263 &edge_values,
1264 &accepted_graph_fields,
1265 clause.target.as_deref(),
1266 )?
1267 {
1268 continue;
1269 }
1270 }
1271 merge_with_clauses(
1272 &mut metadata,
1273 query.ttl_ms,
1274 query.expires_at_ms,
1275 &query.with_metadata,
1276 );
1277 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1278 apply_collection_default_ttl_metadata(
1279 self,
1280 &query.table,
1281 &mut metadata,
1282 );
1283 let (columns, values) = pairwise_columns_values(&edge_values);
1284 let label = find_column_value_string(&columns, &values, "label")?;
1285 ensure_non_tree_structural_edge_label(&label)?;
1286 let from_id = resolve_edge_endpoint_any(
1287 self.inner.db.store().as_ref(),
1288 &query.table,
1289 &columns,
1290 &values,
1291 &["from_rid", "from"],
1292 )?;
1293 let to_id = resolve_edge_endpoint_any(
1294 self.inner.db.store().as_ref(),
1295 &query.table,
1296 &columns,
1297 &values,
1298 &["to_rid", "to"],
1299 )?;
1300 let weight = find_column_value_f32_opt(&columns, &values, "weight");
1301 let properties = extract_remaining_properties(
1302 &columns,
1303 &values,
1304 &["label", "from_rid", "to_rid", "from", "to", "weight"],
1305 );
1306 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1307 properties.iter().map(|(key, _)| key.as_str()),
1308 &format!("edge '{}'", query.table),
1309 )?;
1310 accepted_graph_fields.push(edge_values.clone());
1311 prepared.push(PreparedGraphInsert::Edge {
1312 fields: edge_values,
1313 input: CreateEdgeInput {
1314 collection: query.table.clone(),
1315 label,
1316 from: EntityId::new(from_id),
1317 to: EntityId::new(to_id),
1318 weight,
1319 properties,
1320 metadata,
1321 },
1322 });
1323 }
1324 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1325 }
1326 }
1327
1328 ensure_graph_insert_contract(self, &query.table)?;
1329 let mut batch = self.inner.db.batch();
1330 let mut graph_index_fields: Vec<Vec<(String, Value)>> =
1331 Vec::with_capacity(prepared.len());
1332 for item in prepared {
1333 match item {
1334 PreparedGraphInsert::Node { fields, input } => {
1335 if query.returning.is_some() {
1336 returning_field_snaps.push(fields.clone());
1337 }
1338 graph_index_fields.push(fields);
1339 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1340 batch = batch.add_node_with_type(
1341 input.collection,
1342 input.label,
1343 node_type,
1344 input.properties.into_iter().collect(),
1345 input.metadata.into_iter().collect(),
1346 );
1347 }
1348 PreparedGraphInsert::Edge { fields, input } => {
1349 if query.returning.is_some() {
1350 returning_field_snaps.push(fields.clone());
1351 }
1352 graph_index_fields.push(fields);
1353 batch = batch.add_edge(
1354 input.collection,
1355 input.label,
1356 input.from,
1357 input.to,
1358 input.weight.unwrap_or(1.0),
1359 input.properties.into_iter().collect(),
1360 input.metadata.into_iter().collect(),
1361 );
1362 }
1363 }
1364 }
1365 let batch_result = batch
1366 .with_graph_xmin(self.current_xid().or_else(|| self.graph_insert_xid()))
1367 .with_graph_branch(self.vcs_writer_epoch(&query.table)?)
1368 .execute()
1369 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1370 let (ids, entity_kind) = match query.entity_type {
1371 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1372 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1373 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1374 };
1375 #[cfg(test)]
1376 self.index_store_ref().mutation_test_hook(
1377 &query.table,
1378 super::index_store::MutationTestPhase::StoragePublished,
1379 );
1380 if !graph_index_fields.is_empty() {
1381 let index_rows: Vec<_> = ids
1382 .iter()
1383 .zip(graph_index_fields)
1384 .map(|(id, fields)| (*id, fields))
1385 .collect();
1386 self.inner
1387 .index_store
1388 .index_entity_insert_batch(&query.table, &index_rows)
1389 .map_err(RedDBError::Internal)?;
1390 }
1391 if query.returning.is_some() {
1392 returning_field_snaps = graph_insert_returning_snapshots(
1393 self.inner.db.store().as_ref(),
1394 &query.table,
1395 &ids,
1396 );
1397 }
1398 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind)?;
1399 let store = self.inner.db.store();
1400 entity_outputs.extend(ids.iter().map(|id| {
1401 crate::application::entity::CreateEntityOutput {
1402 id: *id,
1403 entity: store.get(&query.table, *id),
1404 }
1405 }));
1406 inserted_count += ids.len() as u64;
1407 } else {
1408 for row_values in &effective_rows {
1409 if row_values.len() != query.columns.len() {
1410 return Err(RedDBError::Query(format!(
1411 "INSERT column count ({}) does not match value count ({})",
1412 query.columns.len(),
1413 row_values.len()
1414 )));
1415 }
1416
1417 match query.entity_type {
1418 InsertEntityType::Row => {
1419 if query.returning.is_some() {
1420 return Err(RedDBError::Query(
1421 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1422 .to_string(),
1423 ));
1424 }
1425 let (fields, mut metadata) = split_insert_metadata(
1426 self,
1427 &secret_columns,
1428 &query.columns,
1429 row_values,
1430 )?;
1431 merge_with_clauses(
1432 &mut metadata,
1433 query.ttl_ms,
1434 query.expires_at_ms,
1435 &query.with_metadata,
1436 );
1437 self.insert_timeseries_point(&query.table, fields, metadata)?;
1438 }
1439 InsertEntityType::Node | InsertEntityType::Edge => {
1440 unreachable!("NODE and EDGE are handled by the prepared graph path")
1441 }
1442 InsertEntityType::Vector => {
1443 let (vector_values, mut metadata) = split_insert_metadata(
1444 self,
1445 &secret_columns,
1446 &query.columns,
1447 row_values,
1448 )?;
1449 merge_with_clauses(
1450 &mut metadata,
1451 query.ttl_ms,
1452 query.expires_at_ms,
1453 &query.with_metadata,
1454 );
1455 let (columns, values) = pairwise_columns_values(&vector_values);
1456 let dense = find_column_value_vec_f32_any(
1457 &columns,
1458 &values,
1459 &["dense", "embedding"],
1460 )?;
1461 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1462 let content =
1463 find_column_value_opt_string(&columns, &values, "content");
1464 if query.returning.is_some() {
1465 returning_field_snaps.push(vector_values.clone());
1466 }
1467 let input = CreateVectorInput {
1468 collection: query.table.clone(),
1469 dense,
1470 content,
1471 metadata,
1472 link_row: None,
1473 link_node: None,
1474 };
1475 entity_outputs.push(self.create_vector(input)?);
1476 }
1477 InsertEntityType::Document => {
1478 let (document_values, mut metadata) = split_insert_metadata(
1479 self,
1480 &secret_columns,
1481 &query.columns,
1482 row_values,
1483 )?;
1484 let (columns, values) = pairwise_columns_values(&document_values);
1485 let body = find_document_body_json(&columns, &values)?;
1486 let conflict_fields =
1487 document_on_conflict_fields(&document_values, &body)?;
1488 if let Some(clause) = &query.on_conflict {
1489 if let Some(conflict_id) = self
1490 .on_conflict_unique_index_conflict_id(
1491 &query.table,
1492 &conflict_fields,
1493 clause.target.as_deref(),
1494 )?
1495 {
1496 match &clause.action {
1497 OnConflictAction::DoNothing => continue,
1498 OnConflictAction::DoUpdate { assignments } => {
1499 let updated = self
1500 .execute_unique_index_conflict_update(
1501 raw_query,
1502 query,
1503 conflict_id,
1504 UpdateTarget::Documents,
1505 &conflict_fields,
1506 assignments,
1507 )?;
1508 inserted_count += updated.affected_rows;
1509 conflict_returning_records
1510 .extend(updated.result.records);
1511 continue;
1512 }
1513 }
1514 }
1515 }
1516 merge_with_clauses(
1517 &mut metadata,
1518 query.ttl_ms,
1519 query.expires_at_ms,
1520 &query.with_metadata,
1521 );
1522 let input = CreateDocumentInput {
1523 collection: query.table.clone(),
1524 body,
1525 metadata,
1526 node_links: Vec::new(),
1527 vector_links: Vec::new(),
1528 };
1529 let output = self.create_document(input)?;
1530 if query.returning.is_some() {
1531 let fields = output
1532 .entity
1533 .as_ref()
1534 .map(entity_row_fields_snapshot)
1535 .filter(|fields| !fields.is_empty())
1536 .unwrap_or(document_values);
1537 returning_field_snaps.push(fields);
1538 }
1539 entity_outputs.push(output);
1540 }
1541 InsertEntityType::Kv => {
1542 let (kv_values, mut metadata) = split_insert_metadata(
1543 self,
1544 &secret_columns,
1545 &query.columns,
1546 row_values,
1547 )?;
1548 merge_with_clauses(
1549 &mut metadata,
1550 query.ttl_ms,
1551 query.expires_at_ms,
1552 &query.with_metadata,
1553 );
1554 let (columns, values) = pairwise_columns_values(&kv_values);
1555 let key = find_column_value_string(&columns, &values, "key")?;
1556 let value = find_column_value(&columns, &values, "value")?;
1557 if query.returning.is_some() {
1558 returning_field_snaps.push(kv_values.clone());
1559 }
1560 let input = CreateKvInput {
1561 collection: query.table.clone(),
1562 key,
1563 value,
1564 metadata,
1565 };
1566 entity_outputs.push(self.create_kv(input)?);
1567 }
1568 }
1569
1570 inserted_count += 1;
1571 }
1572 }
1573
1574 if query.auto_embed.is_some() {
1575 auto_embed_ids.extend(entity_outputs.iter().map(|output| output.id));
1576 }
1577
1578 if let Some(items) = query.returning.as_ref() {
1579 let mut result =
1580 build_returning_result(items, &returning_field_snaps, Some(&entity_outputs));
1581 result
1582 .records
1583 .splice(0..0, conflict_returning_records.drain(..));
1584 returning_result = Some(result);
1585 }
1586 }
1587
1588 if let Some((provider, model, connection, fields)) = resolved_auto_embed.as_ref() {
1590 let store = self.inner.db.store();
1591 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1592 let (api_key, api_base) = connection
1593 .as_ref()
1594 .map(|connection| (connection.api_key.as_str(), connection.api_base.as_str()))
1595 .unwrap_or(("", ""));
1596
1597 let recent: Vec<_> = auto_embed_ids
1601 .iter()
1602 .filter_map(|id| store.get(&query.table, *id))
1603 .collect();
1604
1605 let entity_combos: Vec<(usize, String)> = recent
1607 .iter()
1608 .enumerate()
1609 .filter_map(|(i, entity)| {
1610 if let EntityData::Row(ref row) = entity.data {
1611 let texts: Vec<String> = fields
1612 .iter()
1613 .filter_map(|field| match row.get_field(field) {
1614 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1615 _ => None,
1616 })
1617 .collect();
1618 if !texts.is_empty() {
1619 return Some((i, texts.join(" ")));
1620 }
1621 }
1622 None
1623 })
1624 .collect();
1625
1626 if !entity_combos.is_empty() {
1627 let batch_texts: Vec<String> =
1629 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1630
1631 let embeddings = if is_local_provider {
1641 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1642 &self.inner.db,
1643 model,
1644 batch_texts,
1645 )?;
1646 response.embeddings
1647 } else {
1648 let batch_client =
1649 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1650
1651 match tokio::runtime::Handle::try_current() {
1652 Ok(handle) => tokio::task::block_in_place(|| {
1653 handle.block_on(batch_client.embed_batch_at(
1654 provider,
1655 model,
1656 api_key,
1657 api_base,
1658 batch_texts,
1659 ))
1660 }),
1661 Err(_) => {
1662 return Err(RedDBError::Query(
1663 "AUTO EMBED requires a Tokio runtime context".to_string(),
1664 ));
1665 }
1666 }
1667 .map_err(|e| RedDBError::Query(e.to_string()))?
1668 };
1669
1670 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1672 if dense.is_empty() {
1673 continue;
1674 }
1675 self.create_vector(CreateVectorInput {
1676 collection: query.table.clone(),
1677 dense,
1678 content: Some(combined.clone()),
1679 metadata: Vec::new(),
1680 link_row: None,
1681 link_node: None,
1682 })?;
1683 }
1684 }
1685 }
1686
1687 if inserted_count > 0 {
1688 self.note_table_write(&query.table);
1689 }
1690
1691 let mut result = RuntimeQueryResult::dml_result(
1692 raw_query.to_string(),
1693 inserted_count,
1694 "insert",
1695 "runtime-dml",
1696 );
1697 if let Some(returning) = returning_result {
1698 result.result = returning;
1699 }
1700 Ok(result)
1701 }
1702
1703 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1704 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1705 return Ok(());
1706 };
1707 if !auth_store.iam_authorization_enabled() {
1708 return Ok(());
1709 }
1710 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1711 return Ok(());
1712 };
1713
1714 let tenant = crate::runtime::impl_core::current_tenant();
1715 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1716 let request = crate::auth::ColumnAccessRequest {
1717 action: "insert".to_string(),
1718 schema: None,
1719 table: query.table.clone(),
1720 columns: query.columns.clone(),
1721 };
1722 let ctx = crate::auth::policies::EvalContext {
1723 principal_tenant: tenant.clone(),
1724 current_tenant: tenant,
1725 peer_ip: None,
1726 mfa_present: false,
1727 now_ms: crate::auth::now_ms(),
1728 principal_is_admin_role: role == crate::auth::Role::Admin,
1729 principal_is_platform_scoped: principal.tenant.is_none(),
1730 };
1731
1732 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1733 let table_allowed = matches!(
1734 outcome.table_decision,
1735 crate::auth::policies::Decision::Allow { .. }
1736 | crate::auth::policies::Decision::AdminBypass
1737 );
1738 if !table_allowed {
1739 return Err(RedDBError::Query(format!(
1740 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1741 outcome.table_resource.kind, outcome.table_resource.name
1742 )));
1743 }
1744 if let Some(denied) = outcome.first_denied_column() {
1745 return Err(RedDBError::Query(format!(
1746 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1747 denied.resource.kind, denied.resource.name
1748 )));
1749 }
1750
1751 Ok(())
1752 }
1753
1754 pub(crate) fn insert_timeseries_point(
1755 &self,
1756 collection: &str,
1757 fields: Vec<(String, Value)>,
1758 metadata: Vec<(String, MetadataValue)>,
1759 ) -> RedDBResult<EntityId> {
1760 if self.vcs_is_versioned(collection)? {
1761 let store = self.db().store();
1762 let _publication = store.vault_publication_lock.lock();
1763 self.with_versioned_graph_insert(|| {
1764 self.insert_timeseries_point_inner(collection, fields, metadata)
1765 })
1766 } else {
1767 self.insert_timeseries_point_inner(collection, fields, metadata)
1768 }
1769 }
1770
1771 fn insert_timeseries_point_inner(
1772 &self,
1773 collection: &str,
1774 mut fields: Vec<(String, Value)>,
1775 mut metadata: Vec<(String, MetadataValue)>,
1776 ) -> RedDBResult<EntityId> {
1777 self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, collection)?;
1778 crate::runtime::mutation::enforce_tenant_fields(self, collection, &mut fields)?;
1779 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1780
1781 let (columns, values) = pairwise_columns_values(&fields);
1782 validate_timeseries_insert_columns(&columns)?;
1783
1784 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1793 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1794 if let Some(event_name) = event_name_opt.as_deref() {
1795 let store_for_schema = self.inner.db.store();
1796 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1797 .is_some()
1798 {
1799 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1800 super::analytics_schema_registry::validate(
1801 store_for_schema.as_ref(),
1802 event_name,
1803 payload_json,
1804 )
1805 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1806 }
1807 }
1808
1809 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1814 Some(m) => m,
1815 None => event_name_opt.clone().ok_or_else(|| {
1816 RedDBError::Query(
1817 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1818 )
1819 })?,
1820 };
1821 let value = match find_column_value_opt_string(&columns, &values, "value") {
1825 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1826 None => columns
1827 .iter()
1828 .position(|c| c.eq_ignore_ascii_case("value"))
1829 .and_then(|i| match &values[i] {
1830 Value::Float(f) => Some(*f),
1831 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1832 Value::UnsignedInteger(n) => Some(*n as f64),
1833 _ => None,
1834 })
1835 .unwrap_or(1.0),
1836 };
1837 let timestamp_ns =
1838 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1839 let mut tags = find_timeseries_tags(&columns, &values)?;
1840 if let Some(ref name) = event_name_opt {
1841 tags.entry("event_name".to_string())
1842 .or_insert_with(|| name.clone());
1843 }
1844 if let Some(ref payload) = payload_opt {
1845 tags.entry("payload".to_string())
1846 .or_insert_with(|| payload.clone());
1847 }
1848 let fields = extract_remaining_properties(
1849 &columns,
1850 &values,
1851 &[
1852 "metric",
1853 "value",
1854 "tags",
1855 "timestamp",
1856 "timestamp_ns",
1857 "time",
1858 "event_name",
1859 "payload",
1860 ],
1861 );
1862 let index_fields = fields.clone();
1863 let _segment_reservation = self.admit_non_evictable_growth(
1864 crate::storage::memory_pools::MemoryPool::SegmentArena,
1865 &format!("insert into {collection}"),
1866 crate::runtime::memory_admission::estimate_timeseries_point_growth(
1867 &metric, &tags, &fields,
1868 ),
1869 )?;
1870 let indexed_columns = self
1871 .inner
1872 .index_store
1873 .list_indices(collection)
1874 .into_iter()
1875 .flat_map(|index| index.columns)
1876 .collect::<Vec<_>>();
1877 let _index_reservation = if indexed_columns.is_empty() {
1878 None
1879 } else {
1880 Some(self.admit_non_evictable_growth(
1881 crate::storage::memory_pools::MemoryPool::IndexMemory,
1882 &format!("index insert into {collection}"),
1883 crate::runtime::memory_admission::estimate_index_growth(
1884 std::slice::from_ref(&index_fields),
1885 &indexed_columns,
1886 ),
1887 )?)
1888 };
1889 let series_id = super::impl_timeseries::intern_timeseries_series(
1890 self.inner.db.store().as_ref(),
1891 collection,
1892 &metric,
1893 &tags,
1894 )?;
1895
1896 let mut entity = UnifiedEntity::new(
1897 EntityId::new(0),
1898 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1899 series: collection.to_string(),
1900 metric: metric.clone(),
1901 })),
1902 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1903 metric,
1904 series_id: Some(series_id),
1905 timestamp_ns,
1906 value,
1907 tags: HashMap::new(),
1908 fields: fields.into_iter().collect(),
1909 }),
1910 );
1911 if !crate::runtime::rls_injection::entity_passes_rls_action(
1912 self,
1913 collection,
1914 reddb_rql::ast::PolicyAction::Insert,
1915 &entity,
1916 ) {
1917 return Err(RedDBError::Query(format!(
1918 "insert into collection '{collection}' denied by row-level security"
1919 )));
1920 }
1921 let writer_xid = match self.current_xid().or_else(|| self.graph_insert_xid()) {
1925 Some(xid) => xid,
1926 None => {
1927 let mgr = self.snapshot_manager();
1928 let xid = mgr.begin();
1929 mgr.commit(xid);
1930 xid
1931 }
1932 };
1933 entity.set_xmin(writer_xid);
1934 self.stamp_vcs_creation(collection, &mut entity)?;
1935
1936 let store = self.inner.db.store();
1937 let id = store
1938 .insert_auto(collection, entity)
1939 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1940 self.inner
1941 .index_store
1942 .index_entity_insert(collection, id, &index_fields)
1943 .map_err(RedDBError::Internal)?;
1944
1945 if !metadata.is_empty() {
1946 store.set_metadata(
1947 collection,
1948 id,
1949 Metadata::with_fields(metadata.into_iter().collect()),
1950 )?;
1951 }
1952
1953 let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1954
1955 self.cdc_emit(
1956 crate::replication::cdc::ChangeOperation::Insert,
1957 collection,
1958 id.raw(),
1959 "timeseries",
1960 )?;
1961
1962 Ok(id)
1963 }
1964
1965 pub fn execute_update(
1970 &self,
1971 raw_query: &str,
1972 query: &UpdateQuery,
1973 ) -> RedDBResult<RuntimeQueryResult> {
1974 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1975 let branch_store = self.db().store();
1976 let _branch_publication = (self.vcs_is_versioned(&query.table)?
1977 || self.has_graph_contract(&query.table))
1978 .then(|| branch_store.vault_publication_lock.lock());
1979 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
1980 self,
1981 &query.table,
1982 );
1983 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
1984 if crate::runtime::blockchain_kind::try_is_chain(
1988 self.inner.db.store().as_ref(),
1989 &query.table,
1990 )? {
1991 return Err(RedDBError::InvalidOperation(format!(
1992 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1993 query.table
1994 )));
1995 }
1996 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
2003 return self.execute_queue_shaped_claim(raw_query, query);
2004 }
2005 let inferred_owned;
2013 let query = match self.resolve_unmarked_update_target(query)? {
2014 Some(rewritten) => {
2015 inferred_owned = rewritten;
2016 &inferred_owned
2017 }
2018 None => query,
2019 };
2020 crate::runtime::collection_contract::CollectionContractGate::check(
2026 self,
2027 &query.table,
2028 crate::runtime::collection_contract::MutationKind::Update,
2029 )?;
2030 ensure_update_target_contract(self, &query.table, query.target)?;
2031 ensure_kv_key_update_target_allowed(query)?;
2032 ensure_graph_identity_update_target_allowed(query)?;
2033
2034 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
2040 let augmented_query: UpdateQuery;
2041 let effective_query: &UpdateQuery = if rls_gated {
2042 let update_filter = crate::runtime::impl_core::rls_policy_filter(
2043 self,
2044 &query.table,
2045 reddb_rql::ast::PolicyAction::Update,
2046 );
2047 let Some(mut policy) = update_filter else {
2048 let mut response = RuntimeQueryResult::dml_result(
2051 raw_query.to_string(),
2052 0,
2053 "update",
2054 "runtime-dml-rls",
2055 );
2056 if let Some(items) = query.returning.clone() {
2057 response.result = build_returning_result(&items, &[], None);
2058 }
2059 return Ok(response);
2060 };
2061 if query.claim_limit.is_some() {
2062 let read_filter = crate::runtime::impl_core::rls_policy_filter(
2063 self,
2064 &query.table,
2065 reddb_rql::ast::PolicyAction::Select,
2066 );
2067 let Some(read_policy) = read_filter else {
2068 let mut response = RuntimeQueryResult::dml_result(
2069 raw_query.to_string(),
2070 0,
2071 "update",
2072 "runtime-dml-rls",
2073 );
2074 if let Some(items) = query.returning.clone() {
2075 response.result = build_returning_result(&items, &[], None);
2076 }
2077 return Ok(response);
2078 };
2079 policy = reddb_rql::ast::Filter::And(Box::new(read_policy), Box::new(policy));
2080 }
2081 let mut augmented = query.clone();
2082 augmented.filter = Some(match augmented.filter.take() {
2083 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2084 None => policy,
2085 });
2086 augmented_query = augmented;
2087 &augmented_query
2088 } else {
2089 query
2090 };
2091
2092 if let Some(items) = effective_query.returning.clone() {
2097 let mut inner_query = effective_query.clone();
2098 inner_query.returning = None;
2099 let (mut response, touched_ids) =
2100 self.execute_update_inner_tracked(raw_query, &inner_query)?;
2101
2102 let mut snapshots = if matches!(
2103 effective_query.target,
2104 UpdateTarget::Nodes | UpdateTarget::Edges
2105 ) {
2106 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
2107 } else {
2108 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
2109 .row_snapshots(&touched_ids)?
2110 };
2111 if matches!(effective_query.target, UpdateTarget::Kv) {
2112 restore_kv_returning_keys(
2113 self,
2114 &effective_query.table,
2115 &touched_ids,
2116 &mut snapshots,
2117 );
2118 }
2119
2120 response.result = build_returning_result(&items, &snapshots, None);
2121 response.engine = "runtime-dml-returning";
2122 return Ok(response);
2123 }
2124
2125 self.execute_update_inner(raw_query, effective_query)
2126 }
2127
2128 fn execute_update_inner(
2130 &self,
2131 raw_query: &str,
2132 query: &UpdateQuery,
2133 ) -> RedDBResult<RuntimeQueryResult> {
2134 self.execute_update_inner_tracked(raw_query, query)
2135 .map(|(res, _)| res)
2136 }
2137
2138 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
2147 if query.claim_limit.is_none()
2148 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
2149 {
2150 return Ok(());
2151 }
2152 let available_indexes: Vec<Vec<String>> = self
2153 .index_store_ref()
2154 .list_indices(&query.table)
2155 .into_iter()
2156 .map(|index| index.columns)
2157 .collect();
2158 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
2159 .map_err(RedDBError::InvalidOperation)
2160 }
2161
2162 fn execute_update_inner_tracked(
2163 &self,
2164 raw_query: &str,
2165 query: &UpdateQuery,
2166 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2167 self.enforce_claim_order_by_index_gate(query)?;
2168 let store = self.inner.db.store();
2169 let topology_lock = self
2170 .index_store_ref()
2171 .collection_topology_lock(&query.table);
2172 let effective_filter = effective_update_filter(query);
2173 let compiled_plan = self.compile_update_plan(query)?;
2174 let autocommit_table_update =
2179 matches!(query.target, UpdateTarget::Rows) && self.current_xid().is_none();
2180 let needs_rmw_lock = autocommit_table_update || update_needs_rmw_lock(query);
2181 let claim_model = update_model_name(update_target_model(query.target));
2182 if query.claim_limit.is_some() {
2183 self.inner
2184 .claim_telemetry
2185 .record_attempt(&query.table, claim_model);
2186 }
2187 let claim_lock = query.claim_limit.map(|_| {
2188 self.inner
2189 .rmw_locks
2190 .lock_for(&query.table, "__table_claim_update__")
2191 });
2192 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2193 let Some(guard) = lock.try_lock() else {
2194 let skipped_locked = query.claim_limit.unwrap_or(1);
2195 self.inner.claim_telemetry.record_skipped_locked(
2196 &query.table,
2197 claim_model,
2198 skipped_locked,
2199 );
2200 tracing::debug!(
2201 target: "reddb::claim",
2202 collection = %query.table,
2203 model = claim_model,
2204 skipped_locked,
2205 "concurrent claim skipped locked candidates"
2206 );
2207 return Ok((
2208 RuntimeQueryResult::dml_result(
2209 raw_query.to_string(),
2210 0,
2211 "update",
2212 "runtime-dml",
2213 ),
2214 Vec::new(),
2215 ));
2216 };
2217 Some(guard)
2218 } else {
2219 None
2220 };
2221 let table_rmw_lock = if needs_rmw_lock {
2222 Some(
2223 self.inner
2224 .rmw_locks
2225 .lock_for(&query.table, "__table_rmw_update__"),
2226 )
2227 } else {
2228 None
2229 };
2230 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2231 let mut touched_ids: Vec<EntityId> = Vec::new();
2232 let claim_cap = query.claim_limit.map(|limit| limit as usize);
2233 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2234 let manager = store
2235 .get_collection(&query.table)
2236 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2237 let scan_limit = if query.order_by.is_empty() {
2238 limit_cap
2239 } else {
2240 None
2241 };
2242 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2243 self,
2244 &query.table,
2245 effective_filter.as_ref(),
2246 scan_limit,
2247 query.target,
2248 );
2249 if needs_rmw_lock && !autocommit_table_update {
2250 target_scan = target_scan.with_live_table_rows();
2251 }
2252 let ids_to_update = {
2253 let _topology_guard = topology_lock.read();
2254 let _fresh_snapshot = if autocommit_table_update {
2260 let mut ctx =
2261 crate::runtime::impl_core::capture_current_snapshot().unwrap_or_else(|| {
2262 let manager = self.snapshot_manager();
2263 crate::runtime::impl_core::SnapshotContext {
2264 branch: None,
2265 snapshot: manager.fresh_read_snapshot(),
2266 manager,
2267 own_xids: std::collections::HashSet::new(),
2268 requires_index_fallback: false,
2269 serializable_reader: None,
2270 }
2271 });
2272 ctx.snapshot = ctx.manager.fresh_read_snapshot();
2273 ctx.branch = self.vcs_branch_read_view(&ctx.snapshot, None)?;
2274 ctx.requires_index_fallback |= !ctx.snapshot.in_progress.is_empty();
2275 Some(crate::runtime::impl_core::CurrentSnapshotGuard::install(
2276 ctx,
2277 ))
2278 } else {
2279 None
2280 };
2281 target_scan.find_target_ids()?
2282 };
2283 let order_limit = if query.claim_limit.is_some() {
2284 None
2285 } else {
2286 limit_cap
2287 };
2288 let ids_to_update = if query.order_by.is_empty() {
2289 ids_to_update
2290 } else {
2291 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)?
2292 };
2293 let mut ids_to_update = if query.claim_limit.is_some() || autocommit_table_update {
2294 self.filter_claim_locked_target_ids(&query.table, ids_to_update)?
2295 } else {
2296 ids_to_update
2297 };
2298 if let Some(claim_cap) = claim_cap {
2299 ids_to_update.truncate(claim_cap);
2300 }
2301 if query.claim_exact
2302 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2303 {
2304 self.inner
2305 .claim_telemetry
2306 .record_miss(&query.table, claim_model);
2307 return Ok((
2308 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2309 Vec::new(),
2310 ));
2311 }
2312
2313 let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
2316 if needs_rmw_lock {
2317 let result = self.execute_update_inner_tracked_locked(
2318 raw_query,
2319 query,
2320 &compiled_plan,
2321 &ids_to_update,
2322 effective_filter.as_ref(),
2323 )?;
2324 if query.claim_limit.is_some() {
2325 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2326 }
2327 record_claim_outcome(
2328 &self.inner.claim_telemetry,
2329 query.claim_limit,
2330 &query.table,
2331 claim_model,
2332 result.0.affected_rows,
2333 );
2334 return Ok(result);
2335 }
2336
2337 let topology_guard = topology_lock.read();
2341 let mut prepared = Vec::new();
2342 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2343 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2344 for id in chunk {
2345 #[cfg(test)]
2346 self.index_store_ref().mutation_test_hook(
2347 &query.table,
2348 super::index_store::MutationTestPhase::DmlPostImageCandidate,
2349 );
2350 super::query_budget::charge(1)?;
2351 let Some(entity) = manager.try_get(*id, usize::MAX)? else {
2352 continue;
2353 };
2354 let assignments =
2355 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2356 let applied = self.apply_materialized_update_for_entity(
2357 query,
2358 entity,
2359 &compiled_plan,
2360 assignments,
2361 )?;
2362 reservations.admit(&applied)?;
2363 touched_ids.push(applied.id);
2364 prepared.push(applied);
2365 }
2366 }
2367 self.validate_graph_changes(
2368 &query.table,
2369 &prepared.iter().map(|item| &item.entity).collect::<Vec<_>>(),
2370 &prepared
2371 .iter()
2372 .map(|item| {
2373 item.replaced_entity
2374 .as_ref()
2375 .map_or(item.id, |entity| entity.id)
2376 })
2377 .collect::<Vec<_>>(),
2378 )?;
2379 let affected = prepared.len() as u64;
2380 let mut published = 0;
2381 let mut _durable_publication = None;
2382 let persistence = (|| -> RedDBResult<()> {
2383 super::query_budget::charge(0)?;
2384 self.enforce_unique_update_post_images(&query.table, &compiled_plan, &prepared)?;
2387 _durable_publication = Some(super::query_budget::begin_durable_publication()?);
2391 for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2392 if compiled_plan.row_touches_unique_columns
2393 || compiled_plan
2394 .row_contract_plan
2395 .as_ref()
2396 .is_some_and(|plan| plan.has_expressions)
2397 {
2398 let db = self.db();
2399 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2400 for item in chunk {
2401 let fields =
2402 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2403 let previous_id =
2404 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2405 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2406 }
2407 }
2408 self.persist_update_chunk(chunk)?;
2409 published += chunk.len();
2410 }
2411 Ok(())
2412 })();
2413 let published_mutations = &prepared[..published];
2416 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2417 if !query.suppress_events {
2418 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2419 }
2420 persistence?;
2421 drop(reservations);
2422
2423 if affected > 0 {
2424 self.note_table_write(&query.table);
2425 }
2426 if query.claim_limit.is_some() {
2427 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2428 }
2429 record_claim_outcome(
2430 &self.inner.claim_telemetry,
2431 query.claim_limit,
2432 &query.table,
2433 claim_model,
2434 affected,
2435 );
2436
2437 Ok((
2438 RuntimeQueryResult::dml_result(
2439 raw_query.to_string(),
2440 affected,
2441 "update",
2442 "runtime-dml",
2443 ),
2444 touched_ids,
2445 ))
2446 }
2447
2448 fn filter_claim_locked_target_ids(
2449 &self,
2450 table: &str,
2451 ids: Vec<EntityId>,
2452 ) -> RedDBResult<Vec<EntityId>> {
2453 let conn_id = current_connection_id();
2454 let locks = self.inner.pending_claim_locks.read();
2455 if locks.is_empty() {
2456 return Ok(ids);
2457 }
2458 let mut admitted = Vec::with_capacity(ids.len());
2459 for id in ids {
2460 super::query_budget::charge(1)?;
2461 if let Some(entity) = self.inner.db.store().try_get(table, id, usize::MAX)? {
2462 let key = (table.to_string(), entity.logical_id());
2463 if locks.get(&key).is_none_or(|owner| *owner == conn_id) {
2464 admitted.push(id);
2465 }
2466 }
2467 }
2468 Ok(admitted)
2469 }
2470
2471 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2472 if self.current_xid().is_none() || ids.is_empty() {
2473 return;
2474 }
2475
2476 let conn_id = current_connection_id();
2477 let store = self.inner.db.store();
2478 let mut locks = self.inner.pending_claim_locks.write();
2479 for id in ids {
2480 let Some(entity) = store.get(table, *id) else {
2481 continue;
2482 };
2483 locks.insert((table.to_string(), entity.logical_id()), conn_id);
2484 }
2485 }
2486
2487 fn execute_update_inner_tracked_locked(
2488 &self,
2489 raw_query: &str,
2490 query: &UpdateQuery,
2491 compiled_plan: &CompiledUpdatePlan,
2492 ids_to_update: &[EntityId],
2493 effective_filter: Option<&Filter>,
2494 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2495 let topology_lock = self
2498 .index_store_ref()
2499 .collection_topology_lock(&query.table);
2500 let topology_guard = topology_lock.read();
2501 let store = self.inner.db.store();
2502 let mut touched_ids = Vec::new();
2503 let mut lock_entries = Vec::new();
2504
2505 for id in ids_to_update {
2506 super::query_budget::charge(1)?;
2507 let Some(candidate) = store.try_get(&query.table, *id, usize::MAX)? else {
2508 continue;
2509 };
2510 let logical_id = candidate.logical_id();
2511 let lock_key = super::RmwLockTable::row_key(logical_id);
2512 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2513 lock_entries.push((lock_key, logical_id, rmw_lock));
2514 }
2515
2516 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2517 lock_entries.dedup_by(|left, right| left.0 == right.0);
2518 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2519
2520 let mut applied_chunk = Vec::new();
2521 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2522 for (_, logical_id, _) in &lock_entries {
2523 #[cfg(test)]
2524 self.index_store_ref().mutation_test_hook(
2525 &query.table,
2526 super::index_store::MutationTestPhase::DmlPostImageCandidate,
2527 );
2528 super::query_budget::charge(1)?;
2529 let Some(entity) =
2530 try_resolve_update_entity_by_logical_id(self, &query.table, *logical_id)?
2531 else {
2532 continue;
2533 };
2534 if let Some(filter) = effective_filter {
2535 if !crate::runtime::query_exec::evaluate_entity_filter_result_with_db(
2536 Some(self.inner.db.as_ref()),
2537 &entity,
2538 filter,
2539 &query.table,
2540 &query.table,
2541 )? {
2542 continue;
2543 }
2544 }
2545
2546 let assignments =
2547 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2548 let applied = self.apply_materialized_update_for_entity(
2549 query,
2550 entity,
2551 compiled_plan,
2552 assignments,
2553 )?;
2554 reservations.admit(&applied)?;
2555 touched_ids.push(applied.id);
2556 applied_chunk.push(applied);
2557 }
2558
2559 self.validate_graph_changes(
2560 &query.table,
2561 &applied_chunk
2562 .iter()
2563 .map(|item| &item.entity)
2564 .collect::<Vec<_>>(),
2565 &applied_chunk
2566 .iter()
2567 .map(|item| {
2568 item.replaced_entity
2569 .as_ref()
2570 .map_or(item.id, |entity| entity.id)
2571 })
2572 .collect::<Vec<_>>(),
2573 )?;
2574 let affected = applied_chunk.len() as u64;
2575 let mut published = 0;
2576 let mut _durable_publication = None;
2577 let persistence = (|| -> RedDBResult<()> {
2578 super::query_budget::charge(0)?;
2579 self.enforce_unique_update_post_images(&query.table, compiled_plan, &applied_chunk)?;
2580 _durable_publication = Some(super::query_budget::begin_durable_publication()?);
2583 for chunk in applied_chunk.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2584 if compiled_plan.row_touches_unique_columns
2585 || compiled_plan
2586 .row_contract_plan
2587 .as_ref()
2588 .is_some_and(|plan| plan.has_expressions)
2589 {
2590 let db = self.db();
2591 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2592 for item in chunk {
2593 let fields =
2594 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2595 let previous_id =
2596 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2597 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2598 }
2599 }
2600 self.persist_update_chunk(chunk)?;
2601 published += chunk.len();
2602 }
2603 Ok(())
2604 })();
2605 let published_mutations = &applied_chunk[..published];
2608 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2609 if !query.suppress_events {
2610 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2611 }
2612 persistence?;
2613
2614 if affected > 0 {
2615 self.note_table_write(&query.table);
2616 }
2617
2618 Ok((
2619 RuntimeQueryResult::dml_result(
2620 raw_query.to_string(),
2621 affected,
2622 "update",
2623 "runtime-dml",
2624 ),
2625 touched_ids,
2626 ))
2627 }
2628
2629 fn enforce_unique_update_post_images(
2630 &self,
2631 collection: &str,
2632 compiled_plan: &CompiledUpdatePlan,
2633 applied: &[AppliedEntityMutation],
2634 ) -> RedDBResult<()> {
2635 if !compiled_plan.touches_unique_index {
2636 return Ok(());
2637 }
2638 let post_images: Vec<_> = applied
2639 .iter()
2640 .map(|item| {
2641 (
2642 item.replaced_entity.as_ref().map_or(item.id, |old| old.id),
2643 crate::application::ports::entity_row_fields_snapshot(&item.entity),
2644 )
2645 })
2646 .collect();
2647 self.enforce_unique_updates(collection, &post_images)
2648 }
2649
2650 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2651 let mut static_field_assignments = Vec::new();
2652 let mut static_metadata_assignments = Vec::new();
2653 let mut dynamic_assignments = Vec::new();
2654 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2655 let mut row_modified_columns = Vec::new();
2656
2657 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2658 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2659 let metadata_key = resolve_sql_ttl_metadata_key(column);
2660 if compound_op.is_some() && metadata_key.is_some() {
2661 return Err(RedDBError::Query(format!(
2662 "compound assignment is only supported for row fields: {column}"
2663 )));
2664 }
2665 if compound_op.is_none() {
2666 if let Ok(value) = fold_expr_to_value(expr.clone()) {
2667 let unknown_text_target = metadata_key.is_none()
2670 && matches!(value, Value::Text(_))
2671 && row_contract_plan
2672 .as_ref()
2673 .and_then(|plan| plan.declared_rules.get(column))
2674 .is_none();
2675 if !unknown_text_target {
2676 if let Some(metadata_key) = metadata_key {
2677 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2678 let (canonical_key, canonical_value) =
2679 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2680 static_metadata_assignments
2681 .push((canonical_key.to_string(), canonical_value));
2682 } else {
2683 let is_secret_column = row_contract_plan
2684 .as_ref()
2685 .and_then(|plan| plan.declared_rules.get(column))
2686 .is_some_and(|rule| {
2687 rule.data_type == reddb_types::DataType::Secret
2688 });
2689 let value =
2690 self.resolve_secret_column_value(value, is_secret_column)?;
2691 static_field_assignments.push((
2692 column.clone(),
2693 normalize_row_update_assignment_with_plan(
2694 &query.table,
2695 column,
2696 value,
2697 row_contract_plan.as_ref(),
2698 )?,
2699 ));
2700 row_modified_columns.push(column.clone());
2701 }
2702 continue;
2703 }
2704 }
2705 }
2706
2707 dynamic_assignments.push(CompiledUpdateAssignment {
2708 column: column.clone(),
2709 expr: expr.clone(),
2710 compound_op,
2711 metadata_key,
2712 row_rule: if metadata_key.is_none() {
2713 if let Some(plan) = row_contract_plan.as_ref() {
2714 if plan.timestamps_enabled
2715 && (column == "created_at" || column == "updated_at")
2716 {
2717 return Err(RedDBError::Query(format!(
2718 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2719 query.table, column
2720 )));
2721 }
2722 if let Some(rule) = plan.declared_rules.get(column) {
2723 Some(rule.clone())
2724 } else if plan.strict_schema {
2725 return Err(RedDBError::Query(format!(
2726 "collection '{}' is strict and does not allow undeclared fields: {}",
2727 query.table, column
2728 )));
2729 } else {
2730 None
2731 }
2732 } else {
2733 None
2734 }
2735 } else {
2736 None
2737 },
2738 });
2739 if metadata_key.is_none() {
2740 row_modified_columns.push(column.clone());
2741 }
2742 }
2743
2744 let row_modified_columns = dedupe_update_columns(row_modified_columns);
2745 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2746 row_modified_columns.iter().any(|column| {
2747 plan.unique_columns
2748 .keys()
2749 .any(|unique| unique.eq_ignore_ascii_case(column))
2750 })
2751 });
2752
2753 let touches_unique_index = self
2754 .index_store_ref()
2755 .touches_unique_index(&query.table, &row_modified_columns);
2756
2757 if let Some(ttl_ms) = query.ttl_ms {
2758 static_metadata_assignments
2759 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2760 }
2761 if let Some(expires_at_ms) = query.expires_at_ms {
2762 static_metadata_assignments.push((
2763 "_expires_at".to_string(),
2764 metadata_u64_to_value(expires_at_ms),
2765 ));
2766 }
2767 for (key, val) in &query.with_metadata {
2768 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2769 }
2770
2771 Ok(CompiledUpdatePlan {
2772 static_field_assignments,
2773 static_metadata_assignments,
2774 dynamic_assignments,
2775 row_contract_plan,
2776 row_modified_columns,
2777 row_touches_unique_columns,
2778 touches_unique_index,
2779 })
2780 }
2781
2782 fn materialize_update_assignments_for_entity(
2783 &self,
2784 query: &UpdateQuery,
2785 entity: &UnifiedEntity,
2786 compiled_plan: &CompiledUpdatePlan,
2787 ) -> RedDBResult<MaterializedUpdateAssignments> {
2788 let mut assignments = MaterializedUpdateAssignments::default();
2789 let mut record: Option<UnifiedRecord> = None;
2790
2791 for assignment in &compiled_plan.dynamic_assignments {
2792 if assignment.compound_op.is_some()
2793 && !matches!(
2794 entity.data,
2795 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2796 )
2797 {
2798 return Err(RedDBError::Query(format!(
2799 "compound assignment is only supported for row or graph UPDATE column '{}'",
2800 assignment.column
2801 )));
2802 }
2803 if record.is_none() {
2804 record = runtime_any_record_from_entity_ref(entity);
2805 }
2806 let Some(record) = record.as_ref() else {
2807 return Err(RedDBError::Query(format!(
2808 "UPDATE could not materialize runtime record for entity {} in '{}'",
2809 entity.id.raw(),
2810 query.table
2811 )));
2812 };
2813 let rhs = super::expr_eval::try_evaluate_runtime_expr_with_db(
2814 Some(self.inner.db.as_ref()),
2815 &assignment.expr,
2816 record,
2817 Some(query.table.as_str()),
2818 Some(query.table.as_str()),
2819 )?
2820 .ok_or_else(|| {
2821 RedDBError::Query(format!(
2822 "failed to evaluate UPDATE expression for column '{}'",
2823 assignment.column
2824 ))
2825 })?;
2826 let value = if let Some(op) = assignment.compound_op {
2827 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2828 } else {
2829 rhs
2830 };
2831
2832 let sensitive = super::join_filter::expression_uses_secret(
2833 &assignment.expr,
2834 record,
2835 Some(&query.table),
2836 Some(&query.table),
2837 );
2838 let secret_target = assignment
2839 .row_rule
2840 .as_ref()
2841 .is_some_and(|rule| rule.data_type == reddb_types::DataType::Secret)
2842 || record
2843 .get(&assignment.column)
2844 .is_some_and(|value| matches!(value, Value::Secret(_)));
2845 if sensitive && !secret_target {
2846 return Err(RedDBError::Query(
2847 "secret-derived UPDATE values require a SECRET target column".into(),
2848 ));
2849 }
2850 if let Some(metadata_key) = assignment.metadata_key {
2851 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2852 let (canonical_key, canonical_value) =
2853 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2854 assignments
2855 .dynamic_metadata_assignments
2856 .push((canonical_key.to_string(), canonical_value));
2857 } else {
2858 assignments.dynamic_field_assignments.push((
2859 assignment.column.clone(),
2860 normalize_row_update_value_for_rule(
2861 &query.table,
2862 self.resolve_secret_column_value(
2863 value,
2864 assignment.row_rule.as_ref().is_some_and(|rule| {
2865 rule.data_type == reddb_types::DataType::Secret
2866 }) || record
2867 .get(&assignment.column)
2868 .is_some_and(|value| matches!(value, Value::Secret(_))),
2869 )?,
2870 assignment.row_rule.as_ref(),
2871 )?,
2872 ));
2873 }
2874 }
2875
2876 Ok(assignments)
2877 }
2878
2879 fn apply_materialized_update_for_entity(
2880 &self,
2881 query: &UpdateQuery,
2882 entity: UnifiedEntity,
2883 compiled_plan: &CompiledUpdatePlan,
2884 mut assignments: MaterializedUpdateAssignments,
2885 ) -> RedDBResult<AppliedEntityMutation> {
2886 if matches!(query.target, UpdateTarget::Kv)
2887 && !compiled_plan
2888 .static_field_assignments
2889 .iter()
2890 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2891 && !assignments
2892 .dynamic_field_assignments
2893 .iter()
2894 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2895 {
2896 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2897 .and_then(|record| record.get("key").cloned())
2898 {
2899 assignments
2900 .dynamic_field_assignments
2901 .push(("key".to_string(), key));
2902 }
2903 }
2904 if matches!(entity.data, EntityData::Row(_)) {
2905 return self.apply_loaded_sql_update_row_core(
2906 query.table.clone(),
2907 entity,
2908 &compiled_plan.static_field_assignments,
2909 assignments.dynamic_field_assignments,
2910 &compiled_plan.static_metadata_assignments,
2911 assignments.dynamic_metadata_assignments,
2912 compiled_plan.row_contract_plan.as_ref(),
2913 &compiled_plan.row_modified_columns,
2914 compiled_plan.row_touches_unique_columns,
2915 );
2916 }
2917
2918 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2919
2920 let operations = build_patch_operations_from_materialized_assignments(
2921 &entity,
2922 compiled_plan,
2923 assignments,
2924 );
2925 self.apply_loaded_patch_entity_core(
2926 query.table.clone(),
2927 entity,
2928 crate::json::Value::Null,
2929 operations,
2930 )
2931 }
2932
2933 pub fn execute_delete(
2935 &self,
2936 raw_query: &str,
2937 query: &DeleteQuery,
2938 ) -> RedDBResult<RuntimeQueryResult> {
2939 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2940 let branch_store = self.db().store();
2941 let _branch_publication = (self.vcs_is_versioned(&query.table)?
2942 || self.has_graph_contract(&query.table))
2943 .then(|| branch_store.vault_publication_lock.lock());
2944 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2945 self,
2946 &query.table,
2947 );
2948 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2949 if crate::runtime::blockchain_kind::try_is_chain(
2952 self.inner.db.store().as_ref(),
2953 &query.table,
2954 )? {
2955 return Err(RedDBError::InvalidOperation(format!(
2956 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2957 query.table
2958 )));
2959 }
2960 crate::runtime::collection_contract::CollectionContractGate::check(
2964 self,
2965 &query.table,
2966 crate::runtime::collection_contract::MutationKind::Delete,
2967 )?;
2968
2969 if let Some(items) = query.returning.clone() {
2976 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2977 RedDBError::Query(
2978 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2979 )
2980 })?;
2981 let captured = self.execute_query(&select_sql)?;
2982
2983 let mut inner_query = query.clone();
2984 inner_query.returning = None;
2985 let _ = self.execute_delete(raw_query, &inner_query)?;
2986
2987 let snapshots: Vec<Vec<(String, Value)>> = captured
2988 .result
2989 .records
2990 .iter()
2991 .map(|rec| {
2992 rec.iter_fields()
2993 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2994 .collect()
2995 })
2996 .collect();
2997 let affected = snapshots.len() as u64;
2998 let result = build_returning_result(&items, &snapshots, None);
2999
3000 let mut response = RuntimeQueryResult::dml_result(
3001 raw_query.to_string(),
3002 affected,
3003 "delete",
3004 "runtime-dml-returning",
3005 );
3006 response.result = result;
3007 return Ok(response);
3008 }
3009 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
3016 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
3017 self,
3018 &query.table,
3019 reddb_rql::ast::PolicyAction::Delete,
3020 );
3021 let Some(policy) = rls_filter else {
3022 return Ok(RuntimeQueryResult::dml_result(
3023 raw_query.to_string(),
3024 0,
3025 "delete",
3026 "runtime-dml-rls",
3027 ));
3028 };
3029 let mut augmented = query.clone();
3034 augmented.filter = Some(match augmented.filter.take() {
3035 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
3036 None => policy,
3037 });
3038 return self.execute_delete_inner(raw_query, &augmented);
3039 }
3040 self.execute_delete_inner(raw_query, query)
3041 }
3042
3043 fn execute_delete_inner(
3044 &self,
3045 raw_query: &str,
3046 query: &DeleteQuery,
3047 ) -> RedDBResult<RuntimeQueryResult> {
3048 let maintenance_limit = super::retention_sweeper::take_delete_limit();
3049 let topology_lock = self
3050 .index_store_ref()
3051 .collection_topology_lock(&query.table);
3052 let effective_filter = effective_delete_filter(query);
3053
3054 let scan = super::dml_target_scan::DmlTargetScan::new(
3058 self,
3059 &query.table,
3060 effective_filter.as_ref(),
3061 maintenance_limit,
3062 );
3063 let ids_to_delete = {
3064 let _topology_guard = topology_lock.read();
3065 let ids = scan.find_target_ids()?;
3066 if ids.len() > UPDATE_APPLY_CHUNK_SIZE
3067 && self.current_xid().is_none()
3068 && UnifiedStore::deferred_store_wal_capture_active()
3069 {
3070 super::delete_publication::order_target_ids(self, &query.table, ids)?
3071 } else {
3072 ids
3073 }
3074 };
3075
3076 let needs_delete_events =
3077 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
3078 let mut affected: u64 = 0;
3079 let _durable_publication = super::query_budget::begin_durable_publication()?;
3083 self.validate_graph_changes(&query.table, &[], &ids_to_delete)?;
3084 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
3085 let deleted = self.delete_entities_batch(
3086 &query.table,
3087 chunk,
3088 topology_lock.read(),
3089 effective_filter.as_ref(),
3090 needs_delete_events,
3091 )?;
3092 #[cfg(test)]
3093 self.index_store_ref().mutation_test_hook(
3094 &query.table,
3095 super::index_store::MutationTestPhase::BeforeEvents,
3096 );
3097 affected += deleted.affected;
3098 if needs_delete_events && !deleted.changes.is_empty() {
3099 let ids: Vec<_> = deleted.changes.iter().map(|(id, _)| *id).collect();
3100 let lsns: Vec<_> = deleted.changes.iter().map(|(_, lsn)| *lsn).collect();
3101 self.emit_delete_events_for_collection(
3102 &query.table,
3103 &ids,
3104 &lsns,
3105 &deleted.pre_images,
3106 )?;
3107 }
3108 }
3109
3110 if affected > 0 {
3111 self.note_table_write(&query.table);
3112 }
3113
3114 Ok(RuntimeQueryResult::dml_result(
3115 raw_query.to_string(),
3116 affected,
3117 "delete",
3118 "runtime-dml",
3119 ))
3120 }
3121}
3122
3123#[cfg(test)]
3124mod tests {
3125 use crate::storage::wal::{WalReader, WalRecord};
3126 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
3127 use crate::{RedDBOptions, RedDBRuntime};
3128 use reddb_types::Value;
3129 use std::path::Path;
3130
3131 #[test]
3132 fn captured_bulk_delete_keeps_global_row_lock_order_across_chunks() {
3133 use std::sync::{
3134 atomic::{AtomicUsize, Ordering},
3135 Arc,
3136 };
3137 let runtime = Arc::new(
3138 RedDBRuntime::with_options(
3139 RedDBOptions::in_memory().with_memory_budget(128 * 1024 * 1024),
3140 )
3141 .expect("bulk lock fixture"),
3142 );
3143 runtime
3144 .execute_query("CREATE TABLE bulk_lock_order (id INTEGER)")
3145 .expect("table");
3146 let count = super::UPDATE_APPLY_CHUNK_SIZE + 16;
3147 for batch in (0..count).collect::<Vec<_>>().chunks(512) {
3148 let values = batch
3149 .iter()
3150 .map(|id| format!("({id})"))
3151 .collect::<Vec<_>>()
3152 .join(",");
3153 runtime
3154 .execute_query(&format!("INSERT INTO bulk_lock_order (id) VALUES {values}"))
3155 .expect("seed chunk boundary");
3156 }
3157 let manager = runtime
3158 .db()
3159 .store()
3160 .get_collection("bulk_lock_order")
3161 .expect("rows");
3162 let mut rows = manager.query_all(|_| true);
3166 rows.sort_unstable_by_key(|entity| entity.id);
3167 for (position, mut entity) in rows.into_iter().enumerate() {
3168 entity.set_logical_id(crate::storage::EntityId::new(
3169 u64::try_from(position + 1).expect("small identity"),
3170 ));
3171 manager
3172 .update(entity)
3173 .expect("independent logical identity");
3174 }
3175 let mut expected: Vec<_> = manager
3176 .query_all(|_| true)
3177 .iter()
3178 .map(|entity| format!("row:{}", entity.logical_id().raw()))
3179 .collect();
3180 expected.sort_unstable();
3181 let calls = Arc::new(AtomicUsize::new(0));
3182 let hook_calls = Arc::clone(&calls);
3183 let weak_runtime = Arc::downgrade(&runtime);
3184 *runtime.index_store_ref().mutation_hook.lock() =
3185 Some(Arc::new(move |collection, phase| {
3186 if collection == "bulk_lock_order"
3187 && phase == super::super::index_store::MutationTestPhase::BeforeEvents
3188 {
3189 let runtime = weak_runtime.upgrade().expect("writer still alive");
3190 let held =
3191 super::super::delete_publication::pending_lock_keys(&runtime, collection);
3192 assert!(!held.is_empty());
3193 assert!(held.len() <= expected.len());
3194 let mismatch = held
3195 .iter()
3196 .zip(&expected)
3197 .position(|(actual, expected)| actual != expected);
3198 assert!(
3199 mismatch.is_none(),
3200 "captured DELETE violated global lock order at position {mismatch:?}"
3201 );
3202 hook_calls.fetch_add(1, Ordering::SeqCst);
3203 }
3204 }));
3205 let result = runtime
3206 .with_deferred_store_wal_for_dml(true, || {
3207 runtime.execute_query("DELETE FROM bulk_lock_order")
3208 })
3209 .expect("captured bulk delete");
3210 assert_eq!(
3211 result.affected_rows,
3212 u64::try_from(count).expect("small fixture")
3213 );
3214 assert!(
3215 calls.load(Ordering::SeqCst) >= 2,
3216 "fixture must cross an apply chunk"
3217 );
3218 assert!(runtime
3219 .execute_query("SELECT id FROM bulk_lock_order")
3220 .expect("deleted rows")
3221 .result
3222 .records
3223 .is_empty());
3224 assert_eq!(runtime.snapshot_manager().oldest_active_xid(), None);
3225 }
3226
3227 #[test]
3228 fn locked_update_recheck_read_failure_preserves_preimages_and_retry() {
3229 use crate::storage::engine::BTree;
3230 use reddb_engine::storage::collection_read::PagedCollection;
3231 use std::sync::Arc;
3232
3233 let directory = tempfile::tempdir().expect("locked update fixture");
3234 let options = RedDBOptions::persistent(directory.path().join("locked-update.rdb"))
3235 .with_memory_budget(128 * 1024 * 1024)
3236 .with_storage_profile(
3237 crate::storage::profile::StorageDeployPreset::PrimaryReplicaDev.selection(),
3238 )
3239 .expect("locked update profile");
3240 let runtime = RedDBRuntime::with_options(options).expect("locked update runtime");
3241 for sql in [
3242 "CREATE TABLE recheck_probe (id INTEGER)",
3243 "INSERT INTO recheck_probe (id) VALUES (1), (2)",
3244 "SET CONFIG fixture.flag = 'ready'",
3245 ] {
3246 runtime.execute_query(sql).expect(sql);
3247 }
3248 let sql = "UPDATE recheck_probe SET id = id + 10 WHERE id = 1 OR CONFIG('fixture.flag') = 'ready'";
3249 let reddb_rql::ast::QueryExpr::Update(query) =
3250 reddb_rql::parse(sql).expect("parse update").query
3251 else {
3252 panic!("expected UPDATE");
3253 };
3254 let compiled = runtime.compile_update_plan(&query).expect("compile update");
3255 let filter = reddb_rql::sql_lowering::effective_update_filter(&query);
3256 let ids = super::super::dml_target_scan::DmlTargetScan::new(
3257 &runtime,
3258 "recheck_probe",
3259 filter.as_ref(),
3260 None,
3261 )
3262 .find_target_ids()
3263 .expect("discover targets before failure");
3264 assert_eq!(ids.len(), 2);
3265 let store = runtime.db().store();
3266 store.persist().expect("publish recheck configuration");
3267 let root = store
3268 .collection_root_page("red.config")
3269 .expect("configuration root");
3270 let manager = store
3271 .get_collection("red.config")
3272 .expect("configuration collection");
3273 let install = |limit| {
3274 let tree = BTree::with_root(Arc::clone(store.pager().expect("pager")), root);
3275 manager.install_backing(
3276 Arc::new(PagedCollection::new(Arc::new(tree))),
3277 root,
3278 store.format_version(),
3279 limit,
3280 manager.count() as u64,
3281 );
3282 };
3283 let assert_ids = |expected: [i64; 2]| {
3284 runtime.invalidate_result_cache();
3285 let rows = runtime
3286 .execute_query("SELECT id FROM recheck_probe ORDER BY id")
3287 .expect("read preimages");
3288 let actual: Vec<_> = rows
3289 .result
3290 .records
3291 .iter()
3292 .map(|row| row.get("id").cloned().expect("row value"))
3293 .collect();
3294 assert_eq!(actual, expected.map(Value::Integer));
3295 };
3296 install(1);
3299 let error = runtime
3300 .execute_update_inner_tracked_locked(sql, &query, &compiled, &ids, filter.as_ref())
3301 .err()
3302 .expect("locked recheck must propagate its read failure");
3303 assert!(error.to_string().contains("read memory limit"), "{error}");
3304 install(4096);
3305 assert_ids([1, 2]);
3306 let retried = runtime
3307 .execute_update_inner_tracked_locked(sql, &query, &compiled, &ids, filter.as_ref())
3308 .expect("retry locked recheck");
3309 assert_eq!(retried.0.affected_rows, 2);
3310 assert_ids([11, 12]);
3311 }
3312
3313 #[test]
3314 fn concurrent_constant_updates_leave_one_visible_row_per_logical_id() {
3315 use std::sync::{Arc, Barrier};
3316
3317 const WRITERS: usize = 3;
3318 const ROUNDS: usize = 8;
3319 let rt = Arc::new(RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap());
3320 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3321 .unwrap();
3322 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3323 .unwrap();
3324 let barrier = Arc::new(Barrier::new(WRITERS));
3325 let handles: Vec<_> = (0..WRITERS)
3326 .map(|writer| {
3327 let rt = Arc::clone(&rt);
3328 let barrier = Arc::clone(&barrier);
3329 std::thread::spawn(move || {
3330 for round in 0..ROUNDS {
3331 barrier.wait();
3332 rt.execute_query(&format!(
3333 "UPDATE capabilities SET observed_at = '{writer}-{round}' WHERE id = 'A'"
3334 ))
3335 .unwrap();
3336 }
3337 })
3338 })
3339 .collect();
3340 for handle in handles {
3341 handle.join().unwrap();
3342 }
3343 let selected = rt
3344 .execute_query("SELECT id, observed_at FROM capabilities WHERE id = 'A'")
3345 .unwrap();
3346 assert_eq!(
3347 selected.result.records.len(),
3348 1,
3349 "concurrent updates published multiple visible versions"
3350 );
3351 }
3352
3353 #[test]
3354 fn locked_constant_updates_preserve_unique_constraints() {
3355 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3356 rt.execute_query("CREATE TABLE accounts (id INT PRIMARY KEY, email TEXT UNIQUE)")
3357 .unwrap();
3358 rt.execute_query("INSERT INTO accounts (id, email) VALUES (1, 'a'), (2, 'b')")
3359 .unwrap();
3360 assert!(rt
3361 .execute_query("UPDATE accounts SET email = 'b' WHERE id = 1")
3362 .is_err());
3363 let selected = rt
3364 .execute_query("SELECT id FROM accounts WHERE email = 'a'")
3365 .unwrap();
3366 assert_eq!(selected.result.records.len(), 1);
3367 }
3368
3369 #[test]
3370 fn concurrent_transactions_do_not_both_commit_the_same_row_update() {
3371 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3372
3373 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3374 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3375 .unwrap();
3376 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3377 .unwrap();
3378 for connection in [41, 42] {
3379 set_current_connection_id(connection);
3380 rt.execute_query("BEGIN").unwrap();
3381 }
3382 set_current_connection_id(41);
3383 rt.execute_query("UPDATE capabilities SET observed_at = 'first' WHERE id = 'A'")
3384 .unwrap();
3385 set_current_connection_id(42);
3386 let competing =
3387 rt.execute_query("UPDATE capabilities SET observed_at = 'second' WHERE id = 'A'");
3388 set_current_connection_id(41);
3389 rt.execute_query("COMMIT").unwrap();
3390 set_current_connection_id(42);
3391 if competing.is_ok() {
3392 assert!(
3393 rt.execute_query("COMMIT").is_err(),
3394 "both writers committed"
3395 );
3396 } else {
3397 rt.execute_query("ROLLBACK").unwrap();
3398 }
3399 clear_current_connection_id();
3400 let selected = rt
3401 .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3402 .unwrap();
3403 assert_eq!(selected.result.records.len(), 1);
3404 }
3405
3406 #[test]
3407 fn transaction_and_autocommit_update_do_not_publish_two_live_versions() {
3408 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3409
3410 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3411 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3412 .unwrap();
3413 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3414 .unwrap();
3415 set_current_connection_id(41);
3416 rt.execute_query("BEGIN").unwrap();
3417 rt.execute_query("UPDATE capabilities SET observed_at = 'transaction' WHERE id = 'A'")
3418 .unwrap();
3419 clear_current_connection_id();
3420 let autocommit =
3421 rt.execute_query("UPDATE capabilities SET observed_at = 'auto' WHERE id = 'A'");
3422 set_current_connection_id(41);
3423 let commit = rt.execute_query("COMMIT");
3424 clear_current_connection_id();
3425 assert!(
3426 autocommit.is_err() || commit.is_err(),
3427 "conflicting writers both committed"
3428 );
3429 let selected = rt
3430 .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3431 .unwrap();
3432 assert_eq!(selected.result.records.len(), 1);
3433 }
3434
3435 #[test]
3436 fn transaction_update_does_not_target_rows_created_after_snapshot() {
3437 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3438
3439 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3440 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3441 .unwrap();
3442 set_current_connection_id(41);
3443 rt.execute_query("BEGIN").unwrap();
3444 rt.execute_query("SELECT id FROM capabilities").unwrap();
3445 clear_current_connection_id();
3446 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'after')")
3447 .unwrap();
3448 set_current_connection_id(41);
3449 let updated = rt
3450 .execute_query("UPDATE capabilities SET observed_at = 'changed' WHERE id = 'A'")
3451 .unwrap();
3452 assert_eq!(
3453 updated.affected_rows, 0,
3454 "updated a row outside the transaction snapshot"
3455 );
3456 rt.execute_query("COMMIT").unwrap();
3457 clear_current_connection_id();
3458 let selected = rt
3459 .execute_query("SELECT observed_at FROM capabilities WHERE id = 'A'")
3460 .unwrap();
3461 assert_eq!(selected.result.records.len(), 1);
3462 assert_eq!(
3463 selected.result.records[0].get("observed_at"),
3464 Some(&Value::text("after"))
3465 );
3466 }
3467
3468 #[test]
3469 fn autocommit_update_does_not_filter_on_uncommitted_row_image() {
3470 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3471
3472 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3473 rt.execute_query("CREATE TABLE capabilities (id TEXT, status TEXT, payload TEXT)")
3474 .unwrap();
3475 rt.execute_query(
3476 "INSERT INTO capabilities (id, status, payload) VALUES ('A', 'ready', 'old')",
3477 )
3478 .unwrap();
3479 set_current_connection_id(41);
3480 rt.execute_query("BEGIN").unwrap();
3481 rt.execute_query("UPDATE capabilities SET status = 'busy' WHERE id = 'A'")
3482 .unwrap();
3483 clear_current_connection_id();
3484 let autocommit =
3485 rt.execute_query("UPDATE capabilities SET payload = 'new' WHERE status = 'ready'");
3486 if let Ok(result) = &autocommit {
3487 assert_eq!(
3488 result.affected_rows, 1,
3489 "uncommitted status hid the committed target"
3490 );
3491 }
3492 set_current_connection_id(41);
3493 rt.execute_query("ROLLBACK").unwrap();
3494 clear_current_connection_id();
3495 let selected = rt
3496 .execute_query("SELECT status, payload FROM capabilities WHERE id = 'A'")
3497 .unwrap();
3498 assert_eq!(selected.result.records.len(), 1);
3499 assert_eq!(
3500 selected.result.records[0].get("status"),
3501 Some(&Value::text("ready"))
3502 );
3503 if autocommit.is_ok() {
3504 assert_eq!(
3505 selected.result.records[0].get("payload"),
3506 Some(&Value::text("new"))
3507 );
3508 }
3509 }
3510
3511 #[test]
3512 fn autocommit_update_skips_row_claimed_by_another_transaction() {
3513 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3514
3515 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3516 rt.execute_query("CREATE TABLE capabilities (id INT, rank INT, status TEXT, note TEXT)")
3517 .unwrap();
3518 rt.execute_query("CREATE INDEX capabilities_rank ON capabilities (rank)")
3519 .unwrap();
3520 rt.execute_query(
3521 "INSERT INTO capabilities (id, rank, status, note) VALUES (1, 10, 'ready', 'old')",
3522 )
3523 .unwrap();
3524 set_current_connection_id(41);
3525 rt.execute_query("BEGIN").unwrap();
3526 let claim = rt.execute_query("UPDATE capabilities SET status = 'claimed' WHERE status = 'ready' CLAIM LIMIT 1 ORDER BY rank ASC").unwrap();
3527 assert_eq!(claim.affected_rows, 1);
3528 set_current_connection_id(42);
3529 let other = rt
3530 .execute_query("UPDATE capabilities SET note = 'new' WHERE id = 1")
3531 .unwrap();
3532 assert_eq!(other.affected_rows, 0);
3533 set_current_connection_id(41);
3534 rt.execute_query("ROLLBACK").unwrap();
3535 clear_current_connection_id();
3536 let rows = rt
3537 .execute_query("SELECT status, note FROM capabilities WHERE id = 1")
3538 .unwrap();
3539 assert_eq!(rows.result.records.len(), 1);
3540 assert_eq!(
3541 rows.result.records[0].get("status"),
3542 Some(&Value::text("ready"))
3543 );
3544 assert_eq!(
3545 rows.result.records[0].get("note"),
3546 Some(&Value::text("old"))
3547 );
3548 }
3549
3550 fn persistent_operational_options(path: &Path) -> RedDBOptions {
3551 RedDBOptions::persistent(path)
3552 .with_storage_profile(StorageProfileSelection {
3553 deploy_profile: DeployProfile::Embedded,
3554 packaging: StoragePackaging::OperationalDirectory,
3555 replica_count: 0,
3556 managed_backup: false,
3557 wal_retention: false,
3558 })
3559 .unwrap()
3560 }
3561
3562 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
3563 WalReader::open(wal_path)
3564 .expect("wal opens")
3565 .iter()
3566 .map(|record| record.expect("wal record decodes").1)
3567 .filter_map(|record| match record {
3568 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
3569 _ => None,
3570 })
3571 .collect()
3572 }
3573
3574 fn action_contains_text(action: &[u8], needle: &str) -> bool {
3575 action
3576 .windows(needle.len())
3577 .any(|window| window == needle.as_bytes())
3578 }
3579
3580 fn claim_metric_count(
3581 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
3582 metric: &str,
3583 collection: &str,
3584 model: &str,
3585 ) -> u64 {
3586 let rows = match metric {
3587 "attempts" => &snapshot.attempts,
3588 "successful" => &snapshot.successful,
3589 "misses" => &snapshot.misses,
3590 "skipped_locked" => &snapshot.skipped_locked,
3591 other => panic!("unknown claim metric {other}"),
3592 };
3593 rows.iter()
3594 .find(|((actual_collection, actual_model), _)| {
3595 actual_collection == collection && actual_model == model
3596 })
3597 .map(|(_, count)| *count)
3598 .unwrap_or(0)
3599 }
3600
3601 fn assert_statement_writes_collections_in_one_new_wal_batch(
3602 rt: &RedDBRuntime,
3603 wal_path: &Path,
3604 statement: &str,
3605 source: &str,
3606 event_queue: &str,
3607 ) {
3608 let before_batches = store_commit_batches(wal_path).len();
3609
3610 rt.execute_query(statement).unwrap();
3611
3612 let batches = store_commit_batches(wal_path);
3613 let statement_batches = &batches[before_batches..];
3614 let source_batch = statement_batches
3615 .iter()
3616 .position(|actions| {
3617 actions.iter().any(|action| {
3618 action_contains_text(action, source)
3619 && !action_contains_text(action, event_queue)
3620 })
3621 })
3622 .expect("source collection write batch is present");
3623 let event_batch = statement_batches
3624 .iter()
3625 .position(|actions| {
3626 actions
3627 .iter()
3628 .any(|action| action_contains_text(action, event_queue))
3629 })
3630 .expect("event queue write batch is present");
3631
3632 assert_eq!(
3633 source_batch, event_batch,
3634 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
3635 );
3636 }
3637
3638 #[test]
3639 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
3640 let dir = tempfile::tempdir().unwrap();
3641 let db_path = dir.path().join("events_dual_write.rdb");
3642 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3643 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3644
3645 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3646 .unwrap();
3647 assert_statement_writes_collections_in_one_new_wal_batch(
3648 &rt,
3649 &wal_path,
3650 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
3651 "users",
3652 "users_events",
3653 );
3654 }
3655
3656 #[test]
3657 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
3658 let dir = tempfile::tempdir().unwrap();
3659 let db_path = dir.path().join("events_update_atomic.rdb");
3660 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3661 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3662
3663 rt.execute_query(
3664 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
3665 )
3666 .unwrap();
3667 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3668 .unwrap();
3669
3670 assert_statement_writes_collections_in_one_new_wal_batch(
3671 &rt,
3672 &wal_path,
3673 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
3674 "users",
3675 "user_updates",
3676 );
3677 }
3678
3679 #[test]
3680 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
3681 let dir = tempfile::tempdir().unwrap();
3682 let db_path = dir.path().join("events_delete_atomic.rdb");
3683 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3684 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3685
3686 rt.execute_query(
3687 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
3688 )
3689 .unwrap();
3690 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3691 .unwrap();
3692
3693 assert_statement_writes_collections_in_one_new_wal_batch(
3694 &rt,
3695 &wal_path,
3696 "DELETE FROM users WHERE id = 1",
3697 "users",
3698 "user_deletes",
3699 );
3700 }
3701
3702 #[test]
3703 fn update_where_id_in_with_hash_index_updates_expected_rows() {
3704 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3705 rt.execute_query("CREATE TABLE users (id INT, score INT)")
3706 .unwrap();
3707 for id in 0..5 {
3708 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
3709 .unwrap();
3710 }
3711 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
3712 .unwrap();
3713
3714 let updated = rt
3715 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
3716 .unwrap();
3717 assert_eq!(updated.affected_rows, 3);
3718
3719 let selected = rt
3720 .execute_query("SELECT id, score FROM users ORDER BY id")
3721 .unwrap();
3722 let scores: Vec<(i64, i64)> = selected
3723 .result
3724 .records
3725 .iter()
3726 .map(|record| {
3727 let id = match record.get("id").unwrap() {
3728 Value::Integer(value) => *value,
3729 other => panic!("expected integer id, got {other:?}"),
3730 };
3731 let score = match record.get("score").unwrap() {
3732 Value::Integer(value) => *value,
3733 other => panic!("expected integer score, got {other:?}"),
3734 };
3735 (id, score)
3736 })
3737 .collect();
3738 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3739 }
3740
3741 #[test]
3748 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3749 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3750 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3751 .unwrap();
3752 for id in 0..5 {
3753 rt.execute_query(&format!(
3754 "INSERT INTO items (id, score) VALUES ({id}, {})",
3755 id * 10
3756 ))
3757 .unwrap();
3758 }
3759 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3760 .unwrap();
3761
3762 let updated_one = rt
3766 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3767 .unwrap();
3768 assert_eq!(updated_one.affected_rows, 1);
3769
3770 let updated_many = rt
3774 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3775 .unwrap();
3776 assert_eq!(updated_many.affected_rows, 2);
3777
3778 let snapshot = rt
3779 .execute_query("SELECT id, score FROM items ORDER BY id")
3780 .unwrap();
3781 let pairs: Vec<(i64, i64)> = snapshot
3782 .result
3783 .records
3784 .iter()
3785 .map(|record| {
3786 let id = match record.get("id").unwrap() {
3787 Value::Integer(value) => *value,
3788 other => panic!("expected integer id, got {other:?}"),
3789 };
3790 let score = match record.get("score").unwrap() {
3791 Value::Integer(value) => *value,
3792 other => panic!("expected integer score, got {other:?}"),
3793 };
3794 (id, score)
3795 })
3796 .collect();
3797 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3798
3799 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3801 assert_eq!(updated_all.affected_rows, 5);
3802 let after = rt
3803 .execute_query("SELECT score FROM items ORDER BY id")
3804 .unwrap();
3805 let scores: Vec<i64> = after
3806 .result
3807 .records
3808 .iter()
3809 .map(|record| match record.get("score").unwrap() {
3810 Value::Integer(value) => *value,
3811 other => panic!("expected integer score, got {other:?}"),
3812 })
3813 .collect();
3814 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3815 }
3816
3817 #[test]
3823 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3824 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3825 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3826 .unwrap();
3827 for id in 0..5 {
3828 rt.execute_query(&format!(
3829 "INSERT INTO items (id, score) VALUES ({id}, {})",
3830 id * 10
3831 ))
3832 .unwrap();
3833 }
3834 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3835 .unwrap();
3836
3837 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3840 assert_eq!(deleted_one.affected_rows, 1);
3841
3842 let deleted_many = rt
3846 .execute_query("DELETE FROM items WHERE score > 25")
3847 .unwrap();
3848 assert_eq!(deleted_many.affected_rows, 2);
3849
3850 let surviving = rt
3851 .execute_query("SELECT id FROM items ORDER BY id")
3852 .unwrap();
3853 let ids: Vec<i64> = surviving
3854 .result
3855 .records
3856 .iter()
3857 .map(|record| match record.get("id").unwrap() {
3858 Value::Integer(value) => *value,
3859 other => panic!("expected integer id, got {other:?}"),
3860 })
3861 .collect();
3862 assert_eq!(ids, vec![0, 1]);
3863
3864 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3866 assert_eq!(deleted_rest.affected_rows, 2);
3867 let empty = rt.execute_query("SELECT id FROM items").unwrap();
3868 assert!(empty.result.records.is_empty());
3869 }
3870
3871 #[test]
3876 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3877 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3878 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3879 .unwrap();
3880
3881 let inserted = rt
3884 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3885 .unwrap();
3886 assert_eq!(inserted.affected_rows, 1);
3887
3888 let update_err = rt
3890 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3891 .unwrap_err();
3892 let msg = format!("{update_err}");
3893 assert!(
3894 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3895 "expected UPDATE rejection message, got: {msg}"
3896 );
3897
3898 let delete_err = rt
3900 .execute_query("DELETE FROM events WHERE id = 1")
3901 .unwrap_err();
3902 let msg = format!("{delete_err}");
3903 assert!(
3904 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3905 "expected DELETE rejection message, got: {msg}"
3906 );
3907
3908 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3911 assert_eq!(surviving.result.records.len(), 1);
3912 }
3913
3914 #[test]
3918 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3919 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3920 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3921 .unwrap();
3922
3923 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3924 .unwrap();
3925 let updated = rt
3926 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3927 .unwrap();
3928 assert_eq!(updated.affected_rows, 1);
3929 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3930 assert_eq!(deleted.affected_rows, 1);
3931 }
3932
3933 #[test]
3934 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3935 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3936 rt.execute_query(
3937 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3938 )
3939 .unwrap();
3940
3941 let inserted = rt
3942 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3943 .unwrap();
3944 assert_eq!(inserted.affected_rows, 1);
3945
3946 let events = queue_payloads(&rt, "audit_log");
3947 assert_eq!(events.len(), 1);
3948 let event = events[0].as_object().expect("event payload object");
3949 assert!(event
3950 .get("event_id")
3951 .and_then(crate::json::Value::as_str)
3952 .is_some_and(|value| !value.is_empty()));
3953 assert_eq!(
3954 event.get("op").and_then(crate::json::Value::as_str),
3955 Some("insert")
3956 );
3957 assert_eq!(
3958 event.get("collection").and_then(crate::json::Value::as_str),
3959 Some("users")
3960 );
3961 assert_eq!(
3962 event.get("id").and_then(crate::json::Value::as_u64),
3963 Some(7)
3964 );
3965 assert!(event
3966 .get("ts")
3967 .and_then(crate::json::Value::as_u64)
3968 .is_some());
3969 assert!(event
3970 .get("lsn")
3971 .and_then(crate::json::Value::as_u64)
3972 .is_some());
3973 assert!(matches!(
3974 event.get("tenant"),
3975 Some(crate::json::Value::Null)
3976 ));
3977 assert!(matches!(
3978 event.get("before"),
3979 Some(crate::json::Value::Null)
3980 ));
3981 let after = event
3982 .get("after")
3983 .and_then(crate::json::Value::as_object)
3984 .expect("after object");
3985 assert_eq!(
3986 after.get("id").and_then(crate::json::Value::as_u64),
3987 Some(7)
3988 );
3989 assert_eq!(
3990 after.get("email").and_then(crate::json::Value::as_str),
3991 Some("a@example.com")
3992 );
3993 }
3994
3995 #[test]
3996 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3997 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3998 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3999 .unwrap();
4000
4001 rt.execute_query(
4002 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
4003 )
4004 .unwrap();
4005
4006 let events = queue_payloads(&rt, "users_events");
4007 assert_eq!(events.len(), 2);
4008 let mut previous_lsn = 0;
4009 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
4010 let object = event.as_object().expect("event payload object");
4011 assert_eq!(
4012 object.get("op").and_then(crate::json::Value::as_str),
4013 Some("insert")
4014 );
4015 assert_eq!(
4016 object.get("id").and_then(crate::json::Value::as_u64),
4017 Some(expected_id)
4018 );
4019 let lsn = object
4020 .get("lsn")
4021 .and_then(crate::json::Value::as_u64)
4022 .expect("event lsn");
4023 assert!(
4024 lsn > previous_lsn,
4025 "event LSNs should increase in row order"
4026 );
4027 previous_lsn = lsn;
4028 let after = object
4029 .get("after")
4030 .and_then(crate::json::Value::as_object)
4031 .expect("after object");
4032 assert_eq!(
4033 after.get("id").and_then(crate::json::Value::as_u64),
4034 Some(expected_id)
4035 );
4036 }
4037 }
4038
4039 #[test]
4040 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
4041 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
4042 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
4043 .expect("create table");
4044 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
4046 .expect("create index");
4047 rt.execute_query(
4048 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
4049 (1, 10, 'ready'), (2, 20, 'ready')",
4050 )
4051 .expect("insert rows");
4052
4053 let claim_lock = rt
4054 .inner
4055 .rmw_locks
4056 .lock_for("claim_metric_locked", "__table_claim_update__");
4057 let _guard = claim_lock.lock();
4058 let updated = rt
4059 .execute_query(
4060 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
4061 CLAIM LIMIT 2 ORDER BY rank ASC",
4062 )
4063 .expect("claim skips locked candidates");
4064
4065 assert_eq!(updated.affected_rows, 0);
4066 let snapshot = rt.claim_telemetry_snapshot();
4067 assert_eq!(
4068 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
4069 1
4070 );
4071 assert_eq!(
4072 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
4073 2
4074 );
4075 assert_eq!(
4076 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
4077 0
4078 );
4079 assert_eq!(
4080 snapshot.skipped_locked.len(),
4081 1,
4082 "skipped-lock labels stay bounded to collection/model"
4083 );
4084 }
4085
4086 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
4087 let result = rt
4088 .execute_query(&format!("QUEUE PEEK {queue} 10"))
4089 .expect("peek queue");
4090 result
4091 .result
4092 .records
4093 .iter()
4094 .map(
4095 |record| match record.get("payload").expect("payload column") {
4096 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
4097 other => panic!("expected JSON queue payload, got {other:?}"),
4098 },
4099 )
4100 .collect()
4101 }
4102
4103 #[test]
4115 fn auto_index_id_fires_on_first_insert() {
4116 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4117 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
4118 .unwrap();
4119
4120 assert!(
4122 rt.index_store_ref()
4123 .find_index_for_column("bench_users", "id")
4124 .is_none(),
4125 "freshly created collection should not have an `id` index"
4126 );
4127
4128 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
4130 .unwrap();
4131
4132 let registered = rt
4134 .index_store_ref()
4135 .find_index_for_column("bench_users", "id")
4136 .expect("auto-index hook should have registered idx_id on first insert");
4137 assert_eq!(registered.name, "idx_id");
4138 assert_eq!(registered.collection, "bench_users");
4139 assert_eq!(registered.columns, vec!["id".to_string()]);
4140 assert!(matches!(
4141 registered.method,
4142 super::super::index_store::IndexMethodKind::Hash
4143 ));
4144
4145 for id in 2..=5 {
4148 rt.execute_query(&format!(
4149 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
4150 id * 10
4151 ))
4152 .unwrap();
4153 }
4154 for id in 1..=5 {
4155 let result = rt
4156 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
4157 .unwrap();
4158 assert_eq!(
4159 result.result.records.len(),
4160 1,
4161 "id={id} should match one row"
4162 );
4163 }
4164
4165 let deleted = rt
4170 .execute_query("DELETE FROM bench_users WHERE id = 3")
4171 .unwrap();
4172 assert_eq!(deleted.affected_rows, 1);
4173 }
4174
4175 #[test]
4180 fn auto_index_id_fires_on_first_bulk_insert() {
4181 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4182 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
4183 .unwrap();
4184
4185 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
4186 .unwrap();
4187
4188 let registered = rt
4189 .index_store_ref()
4190 .find_index_for_column("bench_bulk", "id")
4191 .expect("auto-index hook should fire on first bulk insert");
4192 assert_eq!(registered.name, "idx_id");
4193
4194 for id in 1..=3 {
4196 let result = rt
4197 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
4198 .unwrap();
4199 assert_eq!(result.result.records.len(), 1);
4200 }
4201 }
4202
4203 #[test]
4207 fn auto_index_id_skips_when_no_id_column() {
4208 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4209 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
4210 .unwrap();
4211 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
4212 .unwrap();
4213
4214 assert!(rt
4215 .index_store_ref()
4216 .find_index_for_column("plain", "id")
4217 .is_none());
4218 assert!(rt
4219 .index_store_ref()
4220 .find_index_for_column("plain", "uid")
4221 .is_none());
4222 }
4223
4224 #[test]
4229 fn auto_index_id_skips_when_index_already_exists() {
4230 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4231 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
4232 .unwrap();
4233 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
4235 .unwrap();
4236 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
4237 .unwrap();
4238
4239 let registered = rt
4240 .index_store_ref()
4241 .find_index_for_column("pre", "id")
4242 .expect("user index should still be there");
4243 assert_eq!(
4244 registered.name, "user_idx",
4245 "auto-index hook must not overwrite an existing index"
4246 );
4247 }
4248
4249 #[test]
4253 fn auto_index_id_dropped_with_collection() {
4254 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4255 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
4256 .unwrap();
4257 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
4258 .unwrap();
4259 assert!(rt
4260 .index_store_ref()
4261 .find_index_for_column("ephemeral", "id")
4262 .is_some());
4263
4264 rt.execute_query("DROP TABLE ephemeral").unwrap();
4265
4266 assert!(
4267 rt.index_store_ref()
4268 .find_index_for_column("ephemeral", "id")
4269 .is_none(),
4270 "implicit `idx_id` must be reaped when its collection drops"
4271 );
4272 }
4273
4274 #[test]
4279 fn auto_index_id_disabled_by_config() {
4280 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
4281 let rt = RedDBRuntime::with_options(opts).unwrap();
4282
4283 rt.execute_query("CREATE TABLE off (id INT, score INT)")
4284 .unwrap();
4285 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
4286 .unwrap();
4287
4288 assert!(
4289 rt.index_store_ref()
4290 .find_index_for_column("off", "id")
4291 .is_none(),
4292 "with auto_index_id=false, no implicit index should be created"
4293 );
4294 }
4295
4296 #[test]
4299 fn update_single_row_emits_update_event() {
4300 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4301 rt.execute_query(
4302 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
4303 )
4304 .unwrap();
4305 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4306 .unwrap();
4307
4308 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
4309 .unwrap();
4310
4311 let events = queue_payloads(&rt, "audit_log");
4312 assert_eq!(events.len(), 1, "expected exactly 1 update event");
4313 let event = events[0].as_object().expect("event payload object");
4314 assert_eq!(
4315 event.get("op").and_then(crate::json::Value::as_str),
4316 Some("update")
4317 );
4318 assert_eq!(
4319 event.get("collection").and_then(crate::json::Value::as_str),
4320 Some("users")
4321 );
4322 assert!(event
4323 .get("event_id")
4324 .and_then(crate::json::Value::as_str)
4325 .is_some_and(|v| !v.is_empty()));
4326 let before = event
4327 .get("before")
4328 .and_then(crate::json::Value::as_object)
4329 .expect("before must be an object");
4330 let after = event
4331 .get("after")
4332 .and_then(crate::json::Value::as_object)
4333 .expect("after must be an object");
4334 assert_eq!(
4335 before.get("name").and_then(crate::json::Value::as_str),
4336 Some("Alice"),
4337 "before.name should be the old value"
4338 );
4339 assert_eq!(
4340 after.get("name").and_then(crate::json::Value::as_str),
4341 Some("Bob"),
4342 "after.name should be the new value"
4343 );
4344 }
4345
4346 #[test]
4347 fn update_event_only_includes_changed_fields() {
4348 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4349 rt.execute_query(
4350 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
4351 )
4352 .unwrap();
4353 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
4354 .unwrap();
4355
4356 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
4357 .unwrap();
4358
4359 let events = queue_payloads(&rt, "evts");
4360 assert_eq!(events.len(), 1);
4361 let event = events[0].as_object().unwrap();
4362 let before = event
4363 .get("before")
4364 .and_then(crate::json::Value::as_object)
4365 .unwrap();
4366 let after = event
4367 .get("after")
4368 .and_then(crate::json::Value::as_object)
4369 .unwrap();
4370 assert!(
4372 before.contains_key("name"),
4373 "before must include changed field"
4374 );
4375 assert!(
4376 after.contains_key("name"),
4377 "after must include changed field"
4378 );
4379 assert!(
4381 !before.contains_key("email"),
4382 "before must not include unchanged email"
4383 );
4384 assert!(
4385 !after.contains_key("email"),
4386 "after must not include unchanged email"
4387 );
4388 }
4389
4390 #[test]
4391 fn multi_row_update_emits_one_event_per_row() {
4392 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4393 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
4394 .unwrap();
4395 rt.execute_query(
4396 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
4397 )
4398 .unwrap();
4399
4400 rt.execute_query("UPDATE items SET status = 'done'")
4401 .unwrap();
4402
4403 let events = queue_payloads(&rt, "evts");
4404 assert_eq!(events.len(), 3, "expected one update event per row");
4405 for event in &events {
4406 let obj = event.as_object().unwrap();
4407 assert_eq!(
4408 obj.get("op").and_then(crate::json::Value::as_str),
4409 Some("update")
4410 );
4411 }
4412 }
4413
4414 #[test]
4415 fn delete_single_row_emits_delete_event() {
4416 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4417 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
4418 .unwrap();
4419 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
4420 .unwrap();
4421
4422 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
4423
4424 let events = queue_payloads(&rt, "del_log");
4425 assert_eq!(events.len(), 1);
4426 let event = events[0].as_object().expect("event payload object");
4427 assert_eq!(
4428 event.get("op").and_then(crate::json::Value::as_str),
4429 Some("delete")
4430 );
4431 assert_eq!(
4432 event.get("collection").and_then(crate::json::Value::as_str),
4433 Some("users")
4434 );
4435 assert!(event
4436 .get("event_id")
4437 .and_then(crate::json::Value::as_str)
4438 .is_some_and(|v| !v.is_empty()));
4439 let before = event
4440 .get("before")
4441 .and_then(crate::json::Value::as_object)
4442 .expect("before must be an object for delete");
4443 assert_eq!(
4444 before.get("id").and_then(crate::json::Value::as_u64),
4445 Some(42)
4446 );
4447 assert_eq!(
4448 before.get("name").and_then(crate::json::Value::as_str),
4449 Some("Alice")
4450 );
4451 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
4452 }
4453
4454 #[test]
4455 fn multi_row_delete_emits_one_event_per_row() {
4456 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4457 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
4458 .unwrap();
4459 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
4460 .unwrap();
4461
4462 rt.execute_query("DELETE FROM items").unwrap();
4463
4464 let events = queue_payloads(&rt, "del_log");
4465 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
4466 for event in &events {
4467 let obj = event.as_object().unwrap();
4468 assert_eq!(
4469 obj.get("op").and_then(crate::json::Value::as_str),
4470 Some("delete")
4471 );
4472 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
4473 }
4474 }
4475
4476 #[test]
4477 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
4478 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4479 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
4480 .unwrap();
4481
4482 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4483 .unwrap();
4484 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
4485
4486 let events = queue_payloads(&rt, "evts");
4487 assert!(
4488 events.is_empty(),
4489 "UPDATE-only filter must not emit INSERT or DELETE events"
4490 );
4491 }
4492
4493 #[test]
4496 fn suppress_events_on_insert_emits_no_events() {
4497 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4498 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4499 .unwrap();
4500
4501 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4502 .unwrap();
4503
4504 let events = queue_payloads(&rt, "evts");
4505 assert!(
4506 events.is_empty(),
4507 "SUPPRESS EVENTS must prevent INSERT events"
4508 );
4509 }
4510
4511 #[test]
4512 fn suppress_events_on_update_emits_no_events() {
4513 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4514 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4515 .unwrap();
4516 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4517 .unwrap();
4518 let _ = queue_payloads(&rt, "evts");
4520 rt.execute_query("QUEUE PURGE evts").unwrap();
4522
4523 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
4524 .unwrap();
4525
4526 let events = queue_payloads(&rt, "evts");
4527 assert!(
4528 events.is_empty(),
4529 "SUPPRESS EVENTS must prevent UPDATE events"
4530 );
4531 }
4532
4533 #[test]
4534 fn suppress_events_on_delete_emits_no_events() {
4535 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4536 rt.execute_query(
4537 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
4538 )
4539 .unwrap();
4540 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4541 .unwrap();
4542
4543 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
4544 .unwrap();
4545
4546 let events = queue_payloads(&rt, "evts");
4547 assert!(
4548 events.is_empty(),
4549 "SUPPRESS EVENTS must prevent DELETE events"
4550 );
4551 }
4552
4553 #[test]
4554 fn normal_insert_after_suppress_still_emits() {
4555 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4556 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4557 .unwrap();
4558
4559 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4560 .unwrap();
4561 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
4562 .unwrap();
4563
4564 let events = queue_payloads(&rt, "evts");
4565 assert_eq!(
4566 events.len(),
4567 1,
4568 "only the non-suppressed INSERT should emit"
4569 );
4570 assert_eq!(
4571 events[0].get("id").and_then(crate::json::Value::as_u64),
4572 Some(2)
4573 );
4574 }
4575}