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