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