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