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