1use crate::application::entity::{
10 metadata_from_json, AppliedEntityMutation, CreateDocumentInput, CreateEdgeInput,
11 CreateEntityOutput, CreateKvInput, CreateNodeInput, CreateRowInput, CreateRowsBatchInput,
12 CreateVectorInput, DeleteEntityInput, PatchEntityOperation, PatchEntityOperationType,
13 RowUpdateColumnRule, RowUpdateContractPlan,
14};
15use crate::application::ports::{
16 build_row_update_contract_plan, entity_row_fields_snapshot,
17 normalize_row_update_assignment_with_plan, normalize_row_update_value_for_rule,
18 RuntimeEntityPort,
19};
20use crate::application::ttl_payload::has_internal_ttl_metadata;
21use crate::runtime::mvcc::current_connection_id;
22use crate::storage::query::unified::{
23 sys_key_collection, sys_key_created_at, sys_key_kind, sys_key_rid, sys_key_tenant,
24 sys_key_updated_at, UnifiedRecord, UnifiedResult,
25};
26use crate::storage::unified::MetadataValue;
27use crate::storage::Metadata;
28use reddb_rql::ast::{
29 BinOp, CompareOp, Expr, FieldRef, Filter, OnConflictAction, ReturningItem, UpdateQuery,
30 UpdateTarget,
31};
32use reddb_rql::sql_lowering::{
33 effective_delete_filter, effective_insert_rows, effective_update_filter, fold_expr_to_value,
34};
35use std::collections::HashMap;
36use std::sync::Arc;
37
38use super::*;
39use super::impl_dml_support::*;
43use super::impl_dml_claim::*;
47use super::impl_dml_returning::*;
48use super::impl_dml_update_analysis::*;
49use super::impl_dml_update_target::*;
50pub(super) use super::impl_dml_ttl::{canonicalize_sql_ttl_metadata, resolve_sql_ttl_metadata_key};
56
57const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
58pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
59pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
60
61#[derive(Clone)]
62pub(super) struct CompiledUpdateAssignment {
63 column: String,
64 expr: Expr,
65 compound_op: Option<BinOp>,
66 metadata_key: Option<&'static str>,
67 row_rule: Option<RowUpdateColumnRule>,
68}
69
70pub(super) struct CompiledUpdatePlan {
71 pub(super) static_field_assignments: Vec<(String, Value)>,
72 pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
73 dynamic_assignments: Vec<CompiledUpdateAssignment>,
74 row_contract_plan: Option<RowUpdateContractPlan>,
75 row_modified_columns: Vec<String>,
76 row_touches_unique_columns: bool,
77 touches_unique_index: bool,
79}
80
81#[derive(Default)]
82pub(super) struct MaterializedUpdateAssignments {
83 pub(super) dynamic_field_assignments: Vec<(String, Value)>,
84 pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
85}
86
87fn resolve_excluded_assignment(
88 expr: &Expr,
89 excluded_fields: &[(String, Value)],
90) -> RedDBResult<Expr> {
91 match expr {
92 Expr::Column {
93 field: FieldRef::TableColumn { table, column },
94 span,
95 } if table.eq_ignore_ascii_case("excluded") => {
96 let value = excluded_fields
97 .iter()
98 .find(|(name, _)| name.eq_ignore_ascii_case(column))
99 .map(|(_, value)| value.clone())
100 .ok_or_else(|| {
101 RedDBError::Query(format!(
102 "ON CONFLICT EXCLUDED references unknown insert column '{column}'"
103 ))
104 })?;
105 Ok(Expr::Literal { value, span: *span })
106 }
107 Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
108 op: *op,
109 lhs: Box::new(resolve_excluded_assignment(lhs, excluded_fields)?),
110 rhs: Box::new(resolve_excluded_assignment(rhs, excluded_fields)?),
111 span: *span,
112 }),
113 _ => Ok(expr.clone()),
114 }
115}
116
117fn conflict_columns_match(index_columns: &[String], target_columns: &[String]) -> bool {
118 index_columns.len() == target_columns.len()
119 && index_columns.iter().all(|index_column| {
120 target_columns
121 .iter()
122 .any(|target_column| target_column.eq_ignore_ascii_case(index_column))
123 })
124}
125
126fn conflict_index_key(fields: &[(String, Value)], columns: &[String]) -> Option<Vec<Value>> {
127 columns
128 .iter()
129 .map(|column| {
130 super::index_store::index_field_value(fields, column)
131 .filter(|value| !matches!(value.as_ref(), Value::Null))
132 .map(|value| value.into_owned())
133 })
134 .collect()
135}
136
137fn document_on_conflict_fields(
138 stored_fields: &[(String, Value)],
139 body: &crate::json::Value,
140) -> RedDBResult<Vec<(String, Value)>> {
141 let mut fields = stored_fields.to_vec();
142 if let crate::json::Value::Object(object) = body {
143 for (name, value) in object {
144 fields.push((
145 name.clone(),
146 crate::application::entity::json_to_storage_value(value)?,
147 ));
148 }
149 }
150 Ok(fields)
151}
152
153impl RedDBRuntime {
154 fn conflict_unique_indexes(
155 &self,
156 collection: &str,
157 target: Option<&[String]>,
158 ) -> RedDBResult<Vec<super::index_store::RegisteredIndex>> {
159 let mut indexes = self
160 .index_store_ref()
161 .list_indices(collection)
162 .into_iter()
163 .filter(|index| index.unique)
164 .collect::<Vec<_>>();
165 if let Some(target) = target {
166 indexes.retain(|index| conflict_columns_match(&index.columns, target));
167 if indexes.is_empty() {
168 return Err(RedDBError::Query(format!(
169 "no unique index on collection '{}' matches ON CONFLICT ({})",
170 collection,
171 target.join(", ")
172 )));
173 }
174 }
175 Ok(indexes)
176 }
177
178 fn unique_index_conflict_id(
179 &self,
180 collection: &str,
181 fields: &[(String, Value)],
182 target: Option<&[String]>,
183 ) -> RedDBResult<Option<EntityId>> {
184 let indexes = self.conflict_unique_indexes(collection, target)?;
185 let Some(manager) = self.inner.db.store().get_collection(collection) else {
186 return Ok(None);
187 };
188 for index in indexes {
189 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
190 continue;
191 };
192 for entity in manager.query_all(|_| true) {
193 let Some(record) = runtime_any_record_from_entity_ref(&entity) else {
194 continue;
195 };
196 let existing_fields = record
197 .iter_fields()
198 .map(|(name, value)| (name.as_ref().to_string(), value.clone()))
199 .collect::<Vec<_>>();
200 if self.snapshot_manager().is_aborted(entity.xmin) {
201 self.index_store_ref()
202 .index_entity_delete(collection, entity.id, &existing_fields)
203 .map_err(RedDBError::Internal)?;
204 continue;
205 }
206 if conflict_index_key(&existing_fields, &index.columns)
207 .is_some_and(|existing_key| existing_key == proposed_key)
208 {
209 let conflicting_xid = entity.xmin;
210 if self.snapshot_manager().is_active(conflicting_xid)
211 && !self.own_transaction_xids().contains(&conflicting_xid)
212 {
213 return Err(RedDBError::Query(format!(
214 "serialization conflict: ON CONFLICT key in '{collection}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves"
215 )));
216 }
217 return Ok(Some(entity.id));
218 }
219 }
220 }
221 Ok(None)
222 }
223
224 fn on_conflict_unique_index_conflict_id(
225 &self,
226 collection: &str,
227 fields: &[(String, Value)],
228 target: Option<&[String]>,
229 ) -> RedDBResult<Option<EntityId>> {
230 let selected = self.unique_index_conflict_id(collection, fields, target)?;
231 if selected.is_some() || target.is_none() {
232 return Ok(selected);
233 }
234 if self
235 .unique_index_conflict_id(collection, fields, None)?
236 .is_some()
237 {
238 return Err(RedDBError::Query(format!(
239 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
240 target.unwrap_or_default().join(", ")
241 )));
242 }
243 Ok(None)
244 }
245
246 fn has_unique_index_conflict_with_fields(
247 &self,
248 collection: &str,
249 fields: &[(String, Value)],
250 existing_fields: &[Vec<(String, Value)>],
251 target: Option<&[String]>,
252 ) -> RedDBResult<bool> {
253 for index in self.conflict_unique_indexes(collection, target)? {
254 let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
255 continue;
256 };
257 if existing_fields.iter().any(|existing| {
258 conflict_index_key(existing, &index.columns)
259 .is_some_and(|existing_key| existing_key == proposed_key)
260 }) {
261 return Ok(true);
262 }
263 }
264 Ok(false)
265 }
266
267 fn has_on_conflict_unique_index_conflict_with_fields(
268 &self,
269 collection: &str,
270 fields: &[(String, Value)],
271 existing_fields: &[Vec<(String, Value)>],
272 target: Option<&[String]>,
273 ) -> RedDBResult<bool> {
274 let selected = self.has_unique_index_conflict_with_fields(
275 collection,
276 fields,
277 existing_fields,
278 target,
279 )?;
280 if selected || target.is_none() {
281 return Ok(selected);
282 }
283 if self.has_unique_index_conflict_with_fields(collection, fields, existing_fields, None)? {
284 return Err(RedDBError::Query(format!(
285 "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
286 target.unwrap_or_default().join(", ")
287 )));
288 }
289 Ok(false)
290 }
291
292 fn execute_unique_index_conflict_update(
293 &self,
294 raw_query: &str,
295 insert: &InsertQuery,
296 conflict_id: EntityId,
297 target: UpdateTarget,
298 excluded_fields: &[(String, Value)],
299 assignments: &[(String, Expr)],
300 ) -> RedDBResult<RuntimeQueryResult> {
301 let assignment_exprs = assignments
302 .iter()
303 .map(|(column, expr)| {
304 Ok((
305 column.clone(),
306 resolve_excluded_assignment(expr, excluded_fields)?,
307 ))
308 })
309 .collect::<RedDBResult<Vec<_>>>()?;
310 let literal_assignments = assignment_exprs
311 .iter()
312 .filter_map(|(column, expr)| {
313 fold_expr_to_value(expr.clone())
314 .ok()
315 .map(|value| (column.clone(), value))
316 })
317 .collect();
318 let update = UpdateQuery {
319 table: insert.table.clone(),
320 target,
321 compound_assignment_ops: vec![None; assignment_exprs.len()],
322 assignment_exprs,
323 assignments: literal_assignments,
324 where_expr: None,
325 filter: Some(Filter::Compare {
326 field: FieldRef::column("", "rid"),
327 op: CompareOp::Eq,
328 value: Value::UnsignedInteger(conflict_id.raw()),
329 }),
330 ttl_ms: None,
331 expires_at_ms: None,
332 with_metadata: Vec::new(),
333 returning: insert.returning.clone(),
334 claim_limit: None,
335 claim_exact: false,
336 order_by: Vec::new(),
337 limit: None,
338 suppress_events: insert.suppress_events,
339 };
340 self.execute_update(raw_query, &update)
341 }
342
343 fn infer_unmarked_document_insert(
362 &self,
363 query: &InsertQuery,
364 ) -> RedDBResult<Option<InsertQuery>> {
365 if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
366 return Ok(None);
367 }
368 match self
369 .db()
370 .collection_contract_arc(&query.table)
371 .map(|contract| contract.declared_model)
372 {
373 Some(crate::catalog::CollectionModel::Document) => {
374 let mut rewritten = query.clone();
375 rewritten.entity_type = InsertEntityType::Document;
376 rewritten.columns = vec!["body".to_string()];
377 Ok(Some(rewritten))
378 }
379 Some(other) => Err(RedDBError::InvalidOperation(format!(
380 "collection '{table}' is declared as '{model}'; the bare \
381 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
382 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
383 to insert into a {model} collection",
384 table = query.table,
385 model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
386 ))),
387 None => Err(RedDBError::InvalidOperation(format!(
388 "collection '{table}' does not exist and the INSERT carries no model marker; \
389 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
390 (idempotent), or run `CREATE DOCUMENT {table}` first",
391 table = query.table,
392 ))),
393 }
394 }
395
396 fn resolve_unmarked_update_target(
409 &self,
410 query: &UpdateQuery,
411 ) -> RedDBResult<Option<UpdateQuery>> {
412 if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
415 ensure_update_dotted_targets_allowed(query, false)?;
416 return Ok(None);
417 }
418 let declared_model = self
419 .db()
420 .collection_contract_arc(&query.table)
421 .map(|contract| contract.declared_model);
422 let is_document = matches!(
423 declared_model,
424 Some(crate::catalog::CollectionModel::Document)
425 );
426 ensure_update_dotted_targets_allowed(query, is_document)?;
427 let inferred = match declared_model {
428 Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
429 Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
430 _ => return Ok(None),
431 };
432 let mut rewritten = query.clone();
433 rewritten.target = inferred;
434 Ok(Some(rewritten))
435 }
436
437 pub fn execute_insert(
442 &self,
443 raw_query: &str,
444 query: &InsertQuery,
445 ) -> RedDBResult<RuntimeQueryResult> {
446 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
447 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
448 self,
449 &query.table,
450 );
451 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
452 crate::runtime::collection_contract::CollectionContractGate::check(
458 self,
459 &query.table,
460 crate::runtime::collection_contract::MutationKind::Insert,
461 )?;
462 let inferred_owned;
471 let query = match self.infer_unmarked_document_insert(query)? {
472 Some(rewritten) => {
473 inferred_owned = rewritten;
474 &inferred_owned
475 }
476 None => query,
477 };
478 let augmented_owned;
487 let query = match self.maybe_inject_tenant_column(query)? {
488 Some(new_q) => {
489 augmented_owned = new_q;
490 &augmented_owned
491 }
492 None => query,
493 };
494 if query.on_conflict.is_some()
495 && !matches!(
496 query.entity_type,
497 InsertEntityType::Row
498 | InsertEntityType::Document
499 | InsertEntityType::Node
500 | InsertEntityType::Edge
501 )
502 {
503 return Err(RedDBError::InvalidOperation(
504 "ON CONFLICT is supported for table rows, documents, graph nodes, and graph edges"
505 .to_string(),
506 ));
507 }
508 self.check_insert_column_policy(query)?;
509 let resolved_auto_embed = if let Some(ref embed_config) = query.auto_embed {
510 let provider =
514 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
515 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
519 if matches!(provider, crate::ai::AiProvider::Local) {
520 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
521 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
529 if model_name.is_empty() {
530 return Err(RedDBError::Query(
531 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
532 the local provider does not have an implicit default model"
533 .to_string(),
534 ));
535 }
536 crate::runtime::ai::local_embedding::preflight_local_embedding(
537 &self.inner.db,
538 model_name,
539 )?;
540 }
541 let connection = if matches!(provider, crate::ai::AiProvider::Local) {
542 None
543 } else {
544 Some(crate::ai::resolve_connection_from_runtime(
545 &provider, None, self,
546 )?)
547 };
548 let model = crate::ai::resolve_embeddings_model_from_runtime(
549 self,
550 &provider,
551 embed_config.model.as_deref(),
552 );
553 Some((provider, model, connection, &embed_config.fields))
554 } else {
555 None
556 };
557
558 let mut inserted_count: u64 = 0;
559 let effective_rows =
560 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
561 if effective_rows.len() > 1
562 && matches!(
563 query.on_conflict.as_ref().map(|clause| &clause.action),
564 Some(OnConflictAction::DoUpdate { .. })
565 )
566 {
567 return Err(RedDBError::InvalidOperation(
568 "multi-row ON CONFLICT DO UPDATE is not supported atomically; execute one proposed row per statement"
569 .to_string(),
570 ));
571 }
572
573 let store = self.inner.db.store();
575 let _ = store.get_or_create_collection(&query.table);
576 let contract = self.db().collection_contract_arc(&query.table);
577 let declared_model = contract.as_ref().map(|contract| contract.declared_model);
578 let secret_columns: Vec<bool> = query
579 .columns
580 .iter()
581 .map(|column| {
582 contract.as_ref().is_some_and(|contract| {
583 contract.declared_columns.iter().any(|declared| {
584 declared.name == *column
585 && declared.data_type.eq_ignore_ascii_case("secret")
586 })
587 })
588 })
589 .collect();
590 if query.on_conflict.is_some()
591 && matches!(
592 declared_model,
593 Some(crate::catalog::CollectionModel::TimeSeries)
594 )
595 {
596 return Err(RedDBError::InvalidOperation(
597 "ON CONFLICT is not supported for time-series collections".to_string(),
598 ));
599 }
600
601 let conflict_rmw_lock = query.on_conflict.as_ref().map(|_| {
606 self.inner
607 .rmw_locks
608 .lock_for(&query.table, "__collection_on_conflict_insert__")
609 });
610 let _conflict_rmw_guard = conflict_rmw_lock.as_ref().map(|lock| lock.lock());
611
612 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
613 if query.returning.is_some() {
614 Some(Vec::with_capacity(effective_rows.len()))
615 } else {
616 None
617 };
618 let mut returning_result: Option<UnifiedResult> = None;
619 let mut conflict_returning_records: Vec<UnifiedRecord> = Vec::new();
620 let mut auto_embed_ids = Vec::new();
621
622 if matches!(query.entity_type, InsertEntityType::Row)
623 && !matches!(
624 declared_model,
625 Some(crate::catalog::CollectionModel::TimeSeries)
626 )
627 {
628 let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
639 let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = 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::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::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 = crate::runtime::signed_writes_kind::registry(&store, &query.table);
971 crate::runtime::signed_writes_kind::verify_row(
972 ®,
973 pk_col.as_ref().map(|c| c.bytes.as_slice()),
974 sig_col.as_ref().map(|c| c.bytes.as_slice()),
975 &payload,
976 )
977 .map_err(crate::runtime::signed_writes_kind::map_error)?;
978 fields = residual;
979 if let Some(col) = pk_col {
984 fields.push((
985 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
986 col.raw_value,
987 ));
988 }
989 if let Some(col) = sig_col {
990 fields.push((
991 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
992 col.raw_value,
993 ));
994 }
995 }
996 merge_with_clauses(
997 &mut metadata,
998 query.ttl_ms,
999 query.expires_at_ms,
1000 &query.with_metadata,
1001 );
1002 if let Some(snaps) = returning_snapshots.as_mut() {
1003 snaps.push(fields.clone());
1004 }
1005 accepted_row_fields.push(fields.clone());
1006 rows.push(CreateRowInput {
1007 collection: query.table.clone(),
1008 fields,
1009 metadata,
1010 node_links: Vec::new(),
1011 vector_links: Vec::new(),
1012 });
1013 }
1014 let batch = CreateRowsBatchInput {
1015 collection: query.table.clone(),
1016 rows,
1017 suppress_events: query.suppress_events,
1018 };
1019 let outputs = if query
1020 .on_conflict
1021 .as_ref()
1022 .is_some_and(|clause| clause.target.is_none())
1023 {
1024 self.create_rows_batch_prevalidated_with_outputs(batch)?
1025 } else {
1026 self.create_rows_batch(batch)?
1027 };
1028 inserted_count += outputs.len() as u64;
1029 if query.auto_embed.is_some() {
1030 auto_embed_ids.extend(outputs.iter().map(|output| output.id));
1031 }
1032
1033 if chain_mode {
1037 if let Some(new_tip) = chain_tip_full.as_ref() {
1038 self.inner
1039 .chain_tip_cache
1040 .lock()
1041 .insert(query.table.clone(), new_tip.clone());
1042 }
1043 }
1044
1045 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1053 let time_col = &spec.time_column;
1054 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1056 for row in &effective_rows {
1057 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1058 if *n >= 0 {
1059 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1060 }
1061 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1062 let _ = self.inner.db.hypertables().route(&query.table, *n);
1063 }
1064 }
1065 }
1066 }
1067
1068 if let (Some(items), Some(snaps)) =
1069 (query.returning.as_ref(), returning_snapshots.take())
1070 {
1071 let snaps = row_insert_returning_snapshots(&outputs, snaps);
1072 let mut result = build_returning_result(items, &snaps, Some(&outputs));
1073 result
1074 .records
1075 .splice(0..0, conflict_returning_records.drain(..));
1076 returning_result = Some(result);
1077 }
1078 } else {
1079 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1086 Vec::with_capacity(effective_rows.len());
1087 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1088 {
1089 Vec::with_capacity(effective_rows.len())
1090 } else {
1091 Vec::new()
1092 };
1093 if matches!(
1094 query.entity_type,
1095 InsertEntityType::Node | InsertEntityType::Edge
1096 ) {
1097 enum PreparedGraphInsert {
1098 Node {
1099 fields: Vec<(String, Value)>,
1100 input: CreateNodeInput,
1101 },
1102 Edge {
1103 fields: Vec<(String, Value)>,
1104 input: CreateEdgeInput,
1105 },
1106 }
1107
1108 let mut prepared = Vec::with_capacity(effective_rows.len());
1109 let mut accepted_graph_fields: Vec<Vec<(String, Value)>> = Vec::new();
1110 for row_values in &effective_rows {
1111 if row_values.len() != query.columns.len() {
1112 return Err(RedDBError::Query(format!(
1113 "INSERT column count ({}) does not match value count ({})",
1114 query.columns.len(),
1115 row_values.len()
1116 )));
1117 }
1118
1119 match query.entity_type {
1120 InsertEntityType::Node => {
1121 let (node_values, mut metadata) = split_insert_metadata(
1122 self,
1123 &secret_columns,
1124 &query.columns,
1125 row_values,
1126 )?;
1127 if let Some(clause) = &query.on_conflict {
1128 if let Some(conflict_id) = self
1129 .on_conflict_unique_index_conflict_id(
1130 &query.table,
1131 &node_values,
1132 clause.target.as_deref(),
1133 )?
1134 {
1135 match &clause.action {
1136 OnConflictAction::DoNothing => continue,
1137 OnConflictAction::DoUpdate { assignments } => {
1138 let updated = self
1139 .execute_unique_index_conflict_update(
1140 raw_query,
1141 query,
1142 conflict_id,
1143 UpdateTarget::Nodes,
1144 &node_values,
1145 assignments,
1146 )?;
1147 inserted_count += updated.affected_rows;
1148 conflict_returning_records
1149 .extend(updated.result.records);
1150 continue;
1151 }
1152 }
1153 }
1154 if matches!(&clause.action, OnConflictAction::DoNothing)
1155 && self.has_on_conflict_unique_index_conflict_with_fields(
1156 &query.table,
1157 &node_values,
1158 &accepted_graph_fields,
1159 clause.target.as_deref(),
1160 )?
1161 {
1162 continue;
1163 }
1164 }
1165 merge_with_clauses(
1166 &mut metadata,
1167 query.ttl_ms,
1168 query.expires_at_ms,
1169 &query.with_metadata,
1170 );
1171 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1172 apply_collection_default_ttl_metadata(
1173 self,
1174 &query.table,
1175 &mut metadata,
1176 );
1177 let (columns, values) = pairwise_columns_values(&node_values);
1178 let label = find_column_value_string(&columns, &values, "label")?;
1179 let node_type =
1180 find_column_value_opt_string(&columns, &values, "node_type");
1181 let properties = extract_remaining_properties(
1182 &columns,
1183 &values,
1184 &["label", "node_type"],
1185 );
1186 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1187 properties.iter().map(|(key, _)| key.as_str()),
1188 &format!("node '{}'", query.table),
1189 )?;
1190 accepted_graph_fields.push(node_values.clone());
1191 prepared.push(PreparedGraphInsert::Node {
1192 fields: node_values,
1193 input: CreateNodeInput {
1194 collection: query.table.clone(),
1195 label,
1196 node_type,
1197 properties,
1198 metadata,
1199 embeddings: Vec::new(),
1200 table_links: Vec::new(),
1201 node_links: Vec::new(),
1202 },
1203 });
1204 }
1205 InsertEntityType::Edge => {
1206 let (edge_values, mut metadata) = split_insert_metadata(
1207 self,
1208 &secret_columns,
1209 &query.columns,
1210 row_values,
1211 )?;
1212 if let Some(clause) = &query.on_conflict {
1213 if let Some(conflict_id) = self
1214 .on_conflict_unique_index_conflict_id(
1215 &query.table,
1216 &edge_values,
1217 clause.target.as_deref(),
1218 )?
1219 {
1220 match &clause.action {
1221 OnConflictAction::DoNothing => continue,
1222 OnConflictAction::DoUpdate { assignments } => {
1223 let updated = self
1224 .execute_unique_index_conflict_update(
1225 raw_query,
1226 query,
1227 conflict_id,
1228 UpdateTarget::Edges,
1229 &edge_values,
1230 assignments,
1231 )?;
1232 inserted_count += updated.affected_rows;
1233 conflict_returning_records
1234 .extend(updated.result.records);
1235 continue;
1236 }
1237 }
1238 }
1239 if matches!(&clause.action, OnConflictAction::DoNothing)
1240 && self.has_on_conflict_unique_index_conflict_with_fields(
1241 &query.table,
1242 &edge_values,
1243 &accepted_graph_fields,
1244 clause.target.as_deref(),
1245 )?
1246 {
1247 continue;
1248 }
1249 }
1250 merge_with_clauses(
1251 &mut metadata,
1252 query.ttl_ms,
1253 query.expires_at_ms,
1254 &query.with_metadata,
1255 );
1256 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1257 apply_collection_default_ttl_metadata(
1258 self,
1259 &query.table,
1260 &mut metadata,
1261 );
1262 let (columns, values) = pairwise_columns_values(&edge_values);
1263 let label = find_column_value_string(&columns, &values, "label")?;
1264 ensure_non_tree_structural_edge_label(&label)?;
1265 let from_id = resolve_edge_endpoint_any(
1266 self.inner.db.store().as_ref(),
1267 &query.table,
1268 &columns,
1269 &values,
1270 &["from_rid", "from"],
1271 )?;
1272 let to_id = resolve_edge_endpoint_any(
1273 self.inner.db.store().as_ref(),
1274 &query.table,
1275 &columns,
1276 &values,
1277 &["to_rid", "to"],
1278 )?;
1279 let weight = find_column_value_f32_opt(&columns, &values, "weight");
1280 let properties = extract_remaining_properties(
1281 &columns,
1282 &values,
1283 &["label", "from_rid", "to_rid", "from", "to", "weight"],
1284 );
1285 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1286 properties.iter().map(|(key, _)| key.as_str()),
1287 &format!("edge '{}'", query.table),
1288 )?;
1289 accepted_graph_fields.push(edge_values.clone());
1290 prepared.push(PreparedGraphInsert::Edge {
1291 fields: edge_values,
1292 input: CreateEdgeInput {
1293 collection: query.table.clone(),
1294 label,
1295 from: EntityId::new(from_id),
1296 to: EntityId::new(to_id),
1297 weight,
1298 properties,
1299 metadata,
1300 },
1301 });
1302 }
1303 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1304 }
1305 }
1306
1307 ensure_graph_insert_contract(self, &query.table)?;
1308 let mut batch = self.inner.db.batch();
1309 let mut graph_index_fields: Vec<Vec<(String, Value)>> =
1310 Vec::with_capacity(prepared.len());
1311 for item in prepared {
1312 match item {
1313 PreparedGraphInsert::Node { fields, input } => {
1314 if query.returning.is_some() {
1315 returning_field_snaps.push(fields.clone());
1316 }
1317 graph_index_fields.push(fields);
1318 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1319 batch = batch.add_node_with_type(
1320 input.collection,
1321 input.label,
1322 node_type,
1323 input.properties.into_iter().collect(),
1324 input.metadata.into_iter().collect(),
1325 );
1326 }
1327 PreparedGraphInsert::Edge { fields, input } => {
1328 if query.returning.is_some() {
1329 returning_field_snaps.push(fields.clone());
1330 }
1331 graph_index_fields.push(fields);
1332 batch = batch.add_edge(
1333 input.collection,
1334 input.label,
1335 input.from,
1336 input.to,
1337 input.weight.unwrap_or(1.0),
1338 input.properties.into_iter().collect(),
1339 input.metadata.into_iter().collect(),
1340 );
1341 }
1342 }
1343 }
1344 let batch_result = batch
1345 .execute()
1346 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1347 let (ids, entity_kind) = match query.entity_type {
1348 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1349 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1350 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1351 };
1352 for id in &ids {
1353 self.stamp_xmin_if_in_txn(&query.table, *id);
1354 }
1355 if !graph_index_fields.is_empty() {
1356 let index_rows: Vec<_> = ids
1357 .iter()
1358 .zip(graph_index_fields)
1359 .map(|(id, fields)| (*id, fields))
1360 .collect();
1361 self.inner
1362 .index_store
1363 .index_entity_insert_batch(&query.table, &index_rows)
1364 .map_err(RedDBError::Internal)?;
1365 }
1366 if query.returning.is_some() {
1367 returning_field_snaps = graph_insert_returning_snapshots(
1368 self.inner.db.store().as_ref(),
1369 &query.table,
1370 &ids,
1371 );
1372 }
1373 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1374 let store = self.inner.db.store();
1375 entity_outputs.extend(ids.iter().map(|id| {
1376 crate::application::entity::CreateEntityOutput {
1377 id: *id,
1378 entity: store.get(&query.table, *id),
1379 }
1380 }));
1381 inserted_count += ids.len() as u64;
1382 } else {
1383 for row_values in &effective_rows {
1384 if row_values.len() != query.columns.len() {
1385 return Err(RedDBError::Query(format!(
1386 "INSERT column count ({}) does not match value count ({})",
1387 query.columns.len(),
1388 row_values.len()
1389 )));
1390 }
1391
1392 match query.entity_type {
1393 InsertEntityType::Row => {
1394 if query.returning.is_some() {
1395 return Err(RedDBError::Query(
1396 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1397 .to_string(),
1398 ));
1399 }
1400 let (fields, mut metadata) = split_insert_metadata(
1401 self,
1402 &secret_columns,
1403 &query.columns,
1404 row_values,
1405 )?;
1406 merge_with_clauses(
1407 &mut metadata,
1408 query.ttl_ms,
1409 query.expires_at_ms,
1410 &query.with_metadata,
1411 );
1412 self.insert_timeseries_point(&query.table, fields, metadata)?;
1413 }
1414 InsertEntityType::Node | InsertEntityType::Edge => {
1415 unreachable!("NODE and EDGE are handled by the prepared graph path")
1416 }
1417 InsertEntityType::Vector => {
1418 let (vector_values, mut metadata) = split_insert_metadata(
1419 self,
1420 &secret_columns,
1421 &query.columns,
1422 row_values,
1423 )?;
1424 merge_with_clauses(
1425 &mut metadata,
1426 query.ttl_ms,
1427 query.expires_at_ms,
1428 &query.with_metadata,
1429 );
1430 let (columns, values) = pairwise_columns_values(&vector_values);
1431 let dense = find_column_value_vec_f32_any(
1432 &columns,
1433 &values,
1434 &["dense", "embedding"],
1435 )?;
1436 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1437 let content =
1438 find_column_value_opt_string(&columns, &values, "content");
1439 if query.returning.is_some() {
1440 returning_field_snaps.push(vector_values.clone());
1441 }
1442 let input = CreateVectorInput {
1443 collection: query.table.clone(),
1444 dense,
1445 content,
1446 metadata,
1447 link_row: None,
1448 link_node: None,
1449 };
1450 entity_outputs.push(self.create_vector(input)?);
1451 }
1452 InsertEntityType::Document => {
1453 let (document_values, mut metadata) = split_insert_metadata(
1454 self,
1455 &secret_columns,
1456 &query.columns,
1457 row_values,
1458 )?;
1459 let (columns, values) = pairwise_columns_values(&document_values);
1460 let body = find_document_body_json(&columns, &values)?;
1461 let conflict_fields =
1462 document_on_conflict_fields(&document_values, &body)?;
1463 if let Some(clause) = &query.on_conflict {
1464 if let Some(conflict_id) = self
1465 .on_conflict_unique_index_conflict_id(
1466 &query.table,
1467 &conflict_fields,
1468 clause.target.as_deref(),
1469 )?
1470 {
1471 match &clause.action {
1472 OnConflictAction::DoNothing => continue,
1473 OnConflictAction::DoUpdate { assignments } => {
1474 let updated = self
1475 .execute_unique_index_conflict_update(
1476 raw_query,
1477 query,
1478 conflict_id,
1479 UpdateTarget::Documents,
1480 &conflict_fields,
1481 assignments,
1482 )?;
1483 inserted_count += updated.affected_rows;
1484 conflict_returning_records
1485 .extend(updated.result.records);
1486 continue;
1487 }
1488 }
1489 }
1490 }
1491 merge_with_clauses(
1492 &mut metadata,
1493 query.ttl_ms,
1494 query.expires_at_ms,
1495 &query.with_metadata,
1496 );
1497 let input = CreateDocumentInput {
1498 collection: query.table.clone(),
1499 body,
1500 metadata,
1501 node_links: Vec::new(),
1502 vector_links: Vec::new(),
1503 };
1504 let output = self.create_document(input)?;
1505 if query.returning.is_some() {
1506 let fields = output
1507 .entity
1508 .as_ref()
1509 .map(entity_row_fields_snapshot)
1510 .filter(|fields| !fields.is_empty())
1511 .unwrap_or(document_values);
1512 returning_field_snaps.push(fields);
1513 }
1514 entity_outputs.push(output);
1515 }
1516 InsertEntityType::Kv => {
1517 let (kv_values, mut metadata) = split_insert_metadata(
1518 self,
1519 &secret_columns,
1520 &query.columns,
1521 row_values,
1522 )?;
1523 merge_with_clauses(
1524 &mut metadata,
1525 query.ttl_ms,
1526 query.expires_at_ms,
1527 &query.with_metadata,
1528 );
1529 let (columns, values) = pairwise_columns_values(&kv_values);
1530 let key = find_column_value_string(&columns, &values, "key")?;
1531 let value = find_column_value(&columns, &values, "value")?;
1532 if query.returning.is_some() {
1533 returning_field_snaps.push(kv_values.clone());
1534 }
1535 let input = CreateKvInput {
1536 collection: query.table.clone(),
1537 key,
1538 value,
1539 metadata,
1540 };
1541 entity_outputs.push(self.create_kv(input)?);
1542 }
1543 }
1544
1545 inserted_count += 1;
1546 }
1547 }
1548
1549 if query.auto_embed.is_some() {
1550 auto_embed_ids.extend(entity_outputs.iter().map(|output| output.id));
1551 }
1552
1553 if let Some(items) = query.returning.as_ref() {
1554 let mut result =
1555 build_returning_result(items, &returning_field_snaps, Some(&entity_outputs));
1556 result
1557 .records
1558 .splice(0..0, conflict_returning_records.drain(..));
1559 returning_result = Some(result);
1560 }
1561 }
1562
1563 if let Some((provider, model, connection, fields)) = resolved_auto_embed.as_ref() {
1565 let store = self.inner.db.store();
1566 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1567 let (api_key, api_base) = connection
1568 .as_ref()
1569 .map(|connection| (connection.api_key.as_str(), connection.api_base.as_str()))
1570 .unwrap_or(("", ""));
1571
1572 let recent: Vec<_> = auto_embed_ids
1576 .iter()
1577 .filter_map(|id| store.get(&query.table, *id))
1578 .collect();
1579
1580 let entity_combos: Vec<(usize, String)> = recent
1582 .iter()
1583 .enumerate()
1584 .filter_map(|(i, entity)| {
1585 if let EntityData::Row(ref row) = entity.data {
1586 let texts: Vec<String> = fields
1587 .iter()
1588 .filter_map(|field| match row.get_field(field) {
1589 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1590 _ => None,
1591 })
1592 .collect();
1593 if !texts.is_empty() {
1594 return Some((i, texts.join(" ")));
1595 }
1596 }
1597 None
1598 })
1599 .collect();
1600
1601 if !entity_combos.is_empty() {
1602 let batch_texts: Vec<String> =
1604 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1605
1606 let embeddings = if is_local_provider {
1616 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1617 &self.inner.db,
1618 model,
1619 batch_texts,
1620 )?;
1621 response.embeddings
1622 } else {
1623 let batch_client =
1624 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1625
1626 match tokio::runtime::Handle::try_current() {
1627 Ok(handle) => tokio::task::block_in_place(|| {
1628 handle.block_on(batch_client.embed_batch_at(
1629 provider,
1630 model,
1631 api_key,
1632 api_base,
1633 batch_texts,
1634 ))
1635 }),
1636 Err(_) => {
1637 return Err(RedDBError::Query(
1638 "AUTO EMBED requires a Tokio runtime context".to_string(),
1639 ));
1640 }
1641 }
1642 .map_err(|e| RedDBError::Query(e.to_string()))?
1643 };
1644
1645 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1647 if dense.is_empty() {
1648 continue;
1649 }
1650 self.create_vector(CreateVectorInput {
1651 collection: query.table.clone(),
1652 dense,
1653 content: Some(combined.clone()),
1654 metadata: Vec::new(),
1655 link_row: None,
1656 link_node: None,
1657 })?;
1658 }
1659 }
1660 }
1661
1662 if inserted_count > 0 {
1663 self.note_table_write(&query.table);
1664 }
1665
1666 let mut result = RuntimeQueryResult::dml_result(
1667 raw_query.to_string(),
1668 inserted_count,
1669 "insert",
1670 "runtime-dml",
1671 );
1672 if let Some(returning) = returning_result {
1673 result.result = returning;
1674 }
1675 Ok(result)
1676 }
1677
1678 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1679 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1680 return Ok(());
1681 };
1682 if !auth_store.iam_authorization_enabled() {
1683 return Ok(());
1684 }
1685 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1686 return Ok(());
1687 };
1688
1689 let tenant = crate::runtime::impl_core::current_tenant();
1690 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1691 let request = crate::auth::ColumnAccessRequest {
1692 action: "insert".to_string(),
1693 schema: None,
1694 table: query.table.clone(),
1695 columns: query.columns.clone(),
1696 };
1697 let ctx = crate::auth::policies::EvalContext {
1698 principal_tenant: tenant.clone(),
1699 current_tenant: tenant,
1700 peer_ip: None,
1701 mfa_present: false,
1702 now_ms: crate::auth::now_ms(),
1703 principal_is_admin_role: role == crate::auth::Role::Admin,
1704 principal_is_platform_scoped: principal.tenant.is_none(),
1705 };
1706
1707 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1708 let table_allowed = matches!(
1709 outcome.table_decision,
1710 crate::auth::policies::Decision::Allow { .. }
1711 | crate::auth::policies::Decision::AdminBypass
1712 );
1713 if !table_allowed {
1714 return Err(RedDBError::Query(format!(
1715 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1716 outcome.table_resource.kind, outcome.table_resource.name
1717 )));
1718 }
1719 if let Some(denied) = outcome.first_denied_column() {
1720 return Err(RedDBError::Query(format!(
1721 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1722 denied.resource.kind, denied.resource.name
1723 )));
1724 }
1725
1726 Ok(())
1727 }
1728
1729 pub(crate) fn insert_timeseries_point(
1730 &self,
1731 collection: &str,
1732 fields: Vec<(String, Value)>,
1733 mut metadata: Vec<(String, MetadataValue)>,
1734 ) -> RedDBResult<EntityId> {
1735 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1736
1737 let (columns, values) = pairwise_columns_values(&fields);
1738 validate_timeseries_insert_columns(&columns)?;
1739
1740 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1749 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1750 if let Some(event_name) = event_name_opt.as_deref() {
1751 let store_for_schema = self.inner.db.store();
1752 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1753 .is_some()
1754 {
1755 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1756 super::analytics_schema_registry::validate(
1757 store_for_schema.as_ref(),
1758 event_name,
1759 payload_json,
1760 )
1761 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1762 }
1763 }
1764
1765 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1770 Some(m) => m,
1771 None => event_name_opt.clone().ok_or_else(|| {
1772 RedDBError::Query(
1773 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1774 )
1775 })?,
1776 };
1777 let value = match find_column_value_opt_string(&columns, &values, "value") {
1781 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1782 None => columns
1783 .iter()
1784 .position(|c| c.eq_ignore_ascii_case("value"))
1785 .and_then(|i| match &values[i] {
1786 Value::Float(f) => Some(*f),
1787 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1788 Value::UnsignedInteger(n) => Some(*n as f64),
1789 _ => None,
1790 })
1791 .unwrap_or(1.0),
1792 };
1793 let timestamp_ns =
1794 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1795 let mut tags = find_timeseries_tags(&columns, &values)?;
1796 if let Some(ref name) = event_name_opt {
1797 tags.entry("event_name".to_string())
1798 .or_insert_with(|| name.clone());
1799 }
1800 if let Some(ref payload) = payload_opt {
1801 tags.entry("payload".to_string())
1802 .or_insert_with(|| payload.clone());
1803 }
1804 let fields = extract_remaining_properties(
1805 &columns,
1806 &values,
1807 &[
1808 "metric",
1809 "value",
1810 "tags",
1811 "timestamp",
1812 "timestamp_ns",
1813 "time",
1814 "event_name",
1815 "payload",
1816 ],
1817 );
1818 let index_fields = fields.clone();
1819 let _segment_reservation = self.admit_non_evictable_growth(
1820 crate::storage::memory_pools::MemoryPool::SegmentArena,
1821 &format!("insert into {collection}"),
1822 crate::runtime::memory_admission::estimate_timeseries_point_growth(
1823 &metric, &tags, &fields,
1824 ),
1825 )?;
1826 let indexed_columns = self
1827 .inner
1828 .index_store
1829 .list_indices(collection)
1830 .into_iter()
1831 .flat_map(|index| index.columns)
1832 .collect::<Vec<_>>();
1833 let _index_reservation = if indexed_columns.is_empty() {
1834 None
1835 } else {
1836 Some(self.admit_non_evictable_growth(
1837 crate::storage::memory_pools::MemoryPool::IndexMemory,
1838 &format!("index insert into {collection}"),
1839 crate::runtime::memory_admission::estimate_index_growth(
1840 std::slice::from_ref(&index_fields),
1841 &indexed_columns,
1842 ),
1843 )?)
1844 };
1845 let series_id = super::impl_timeseries::intern_timeseries_series(
1846 self.inner.db.store().as_ref(),
1847 collection,
1848 &metric,
1849 &tags,
1850 )?;
1851
1852 let mut entity = UnifiedEntity::new(
1853 EntityId::new(0),
1854 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1855 series: collection.to_string(),
1856 metric: metric.clone(),
1857 })),
1858 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1859 metric,
1860 series_id: Some(series_id),
1861 timestamp_ns,
1862 value,
1863 tags: HashMap::new(),
1864 fields: fields.into_iter().collect(),
1865 }),
1866 );
1867 let writer_xid = match self.current_xid() {
1871 Some(xid) => xid,
1872 None => {
1873 let mgr = self.snapshot_manager();
1874 let xid = mgr.begin();
1875 mgr.commit(xid);
1876 xid
1877 }
1878 };
1879 entity.set_xmin(writer_xid);
1880
1881 let store = self.inner.db.store();
1882 let id = store
1883 .insert_auto(collection, entity)
1884 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1885 self.inner
1886 .index_store
1887 .index_entity_insert(collection, id, &index_fields)
1888 .map_err(RedDBError::Internal)?;
1889
1890 if !metadata.is_empty() {
1891 let _ = store.set_metadata(
1892 collection,
1893 id,
1894 Metadata::with_fields(metadata.into_iter().collect()),
1895 );
1896 }
1897
1898 let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1899
1900 self.cdc_emit(
1901 crate::replication::cdc::ChangeOperation::Insert,
1902 collection,
1903 id.raw(),
1904 "timeseries",
1905 );
1906
1907 Ok(id)
1908 }
1909
1910 pub fn execute_update(
1915 &self,
1916 raw_query: &str,
1917 query: &UpdateQuery,
1918 ) -> RedDBResult<RuntimeQueryResult> {
1919 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1920 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
1921 self,
1922 &query.table,
1923 );
1924 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
1925 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1929 return Err(RedDBError::InvalidOperation(format!(
1930 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1931 query.table
1932 )));
1933 }
1934 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1941 return self.execute_queue_shaped_claim(raw_query, query);
1942 }
1943 let inferred_owned;
1951 let query = match self.resolve_unmarked_update_target(query)? {
1952 Some(rewritten) => {
1953 inferred_owned = rewritten;
1954 &inferred_owned
1955 }
1956 None => query,
1957 };
1958 crate::runtime::collection_contract::CollectionContractGate::check(
1964 self,
1965 &query.table,
1966 crate::runtime::collection_contract::MutationKind::Update,
1967 )?;
1968 ensure_update_target_contract(self, &query.table, query.target)?;
1969 ensure_kv_key_update_target_allowed(query)?;
1970 ensure_graph_identity_update_target_allowed(query)?;
1971
1972 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1978 let augmented_query: UpdateQuery;
1979 let effective_query: &UpdateQuery = if rls_gated {
1980 let update_filter = crate::runtime::impl_core::rls_policy_filter(
1981 self,
1982 &query.table,
1983 reddb_rql::ast::PolicyAction::Update,
1984 );
1985 let Some(mut policy) = update_filter else {
1986 let mut response = RuntimeQueryResult::dml_result(
1989 raw_query.to_string(),
1990 0,
1991 "update",
1992 "runtime-dml-rls",
1993 );
1994 if let Some(items) = query.returning.clone() {
1995 response.result = build_returning_result(&items, &[], None);
1996 }
1997 return Ok(response);
1998 };
1999 if query.claim_limit.is_some() {
2000 let read_filter = crate::runtime::impl_core::rls_policy_filter(
2001 self,
2002 &query.table,
2003 reddb_rql::ast::PolicyAction::Select,
2004 );
2005 let Some(read_policy) = read_filter else {
2006 let mut response = RuntimeQueryResult::dml_result(
2007 raw_query.to_string(),
2008 0,
2009 "update",
2010 "runtime-dml-rls",
2011 );
2012 if let Some(items) = query.returning.clone() {
2013 response.result = build_returning_result(&items, &[], None);
2014 }
2015 return Ok(response);
2016 };
2017 policy = reddb_rql::ast::Filter::And(Box::new(read_policy), Box::new(policy));
2018 }
2019 let mut augmented = query.clone();
2020 augmented.filter = Some(match augmented.filter.take() {
2021 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2022 None => policy,
2023 });
2024 augmented_query = augmented;
2025 &augmented_query
2026 } else {
2027 query
2028 };
2029
2030 if let Some(items) = effective_query.returning.clone() {
2035 let mut inner_query = effective_query.clone();
2036 inner_query.returning = None;
2037 let (mut response, touched_ids) =
2038 self.execute_update_inner_tracked(raw_query, &inner_query)?;
2039
2040 let mut snapshots = if matches!(
2041 effective_query.target,
2042 UpdateTarget::Nodes | UpdateTarget::Edges
2043 ) {
2044 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
2045 } else {
2046 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
2047 .row_snapshots(&touched_ids)
2048 };
2049 if matches!(effective_query.target, UpdateTarget::Kv) {
2050 restore_kv_returning_keys(
2051 self,
2052 &effective_query.table,
2053 &touched_ids,
2054 &mut snapshots,
2055 );
2056 }
2057
2058 response.result = build_returning_result(&items, &snapshots, None);
2059 response.engine = "runtime-dml-returning";
2060 return Ok(response);
2061 }
2062
2063 self.execute_update_inner(raw_query, effective_query)
2064 }
2065
2066 fn execute_update_inner(
2068 &self,
2069 raw_query: &str,
2070 query: &UpdateQuery,
2071 ) -> RedDBResult<RuntimeQueryResult> {
2072 self.execute_update_inner_tracked(raw_query, query)
2073 .map(|(res, _)| res)
2074 }
2075
2076 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
2085 if query.claim_limit.is_none()
2086 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
2087 {
2088 return Ok(());
2089 }
2090 let available_indexes: Vec<Vec<String>> = self
2091 .index_store_ref()
2092 .list_indices(&query.table)
2093 .into_iter()
2094 .map(|index| index.columns)
2095 .collect();
2096 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
2097 .map_err(RedDBError::InvalidOperation)
2098 }
2099
2100 fn execute_update_inner_tracked(
2101 &self,
2102 raw_query: &str,
2103 query: &UpdateQuery,
2104 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2105 self.enforce_claim_order_by_index_gate(query)?;
2106 let store = self.inner.db.store();
2107 let topology_lock = self
2108 .index_store_ref()
2109 .collection_topology_lock(&query.table);
2110 let effective_filter = effective_update_filter(query);
2111 let compiled_plan = self.compile_update_plan(query)?;
2112 let needs_rmw_lock = update_needs_rmw_lock(query);
2113 let claim_model = update_model_name(update_target_model(query.target));
2114 if query.claim_limit.is_some() {
2115 self.inner
2116 .claim_telemetry
2117 .record_attempt(&query.table, claim_model);
2118 }
2119 let claim_lock = query.claim_limit.map(|_| {
2120 self.inner
2121 .rmw_locks
2122 .lock_for(&query.table, "__table_claim_update__")
2123 });
2124 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2125 let Some(guard) = lock.try_lock() else {
2126 let skipped_locked = query.claim_limit.unwrap_or(1);
2127 self.inner.claim_telemetry.record_skipped_locked(
2128 &query.table,
2129 claim_model,
2130 skipped_locked,
2131 );
2132 tracing::debug!(
2133 target: "reddb::claim",
2134 collection = %query.table,
2135 model = claim_model,
2136 skipped_locked,
2137 "concurrent claim skipped locked candidates"
2138 );
2139 return Ok((
2140 RuntimeQueryResult::dml_result(
2141 raw_query.to_string(),
2142 0,
2143 "update",
2144 "runtime-dml",
2145 ),
2146 Vec::new(),
2147 ));
2148 };
2149 Some(guard)
2150 } else {
2151 None
2152 };
2153 let table_rmw_lock = if needs_rmw_lock {
2154 Some(
2155 self.inner
2156 .rmw_locks
2157 .lock_for(&query.table, "__table_rmw_update__"),
2158 )
2159 } else {
2160 None
2161 };
2162 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2163 let mut touched_ids: Vec<EntityId> = Vec::new();
2164 let claim_cap = query.claim_limit.map(|limit| limit as usize);
2165 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2166 let manager = store
2167 .get_collection(&query.table)
2168 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2169 let scan_limit = if query.order_by.is_empty() {
2170 limit_cap
2171 } else {
2172 None
2173 };
2174 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2175 self,
2176 &query.table,
2177 effective_filter.as_ref(),
2178 scan_limit,
2179 query.target,
2180 );
2181 if needs_rmw_lock {
2182 target_scan = target_scan.with_live_table_rows();
2183 }
2184 let ids_to_update = {
2185 let _topology_guard = topology_lock.read();
2186 target_scan.find_target_ids()?
2187 };
2188 let order_limit = if query.claim_limit.is_some() {
2189 None
2190 } else {
2191 limit_cap
2192 };
2193 let ids_to_update = if query.order_by.is_empty() {
2194 ids_to_update
2195 } else {
2196 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
2197 };
2198 let mut ids_to_update = if query.claim_limit.is_some() {
2199 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
2200 } else {
2201 ids_to_update
2202 };
2203 if let Some(claim_cap) = claim_cap {
2204 ids_to_update.truncate(claim_cap);
2205 }
2206 if query.claim_exact
2207 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2208 {
2209 self.inner
2210 .claim_telemetry
2211 .record_miss(&query.table, claim_model);
2212 return Ok((
2213 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2214 Vec::new(),
2215 ));
2216 }
2217
2218 if needs_rmw_lock {
2219 let result = self.execute_update_inner_tracked_locked(
2220 raw_query,
2221 query,
2222 &compiled_plan,
2223 &ids_to_update,
2224 effective_filter.as_ref(),
2225 )?;
2226 if query.claim_limit.is_some() {
2227 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2228 }
2229 record_claim_outcome(
2230 &self.inner.claim_telemetry,
2231 query.claim_limit,
2232 &query.table,
2233 claim_model,
2234 result.0.affected_rows,
2235 );
2236 return Ok(result);
2237 }
2238
2239 let topology_guard = topology_lock.read();
2243 let mut prepared = Vec::new();
2244 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2245 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2246 for entity in manager.get_many(chunk).into_iter().flatten() {
2247 let assignments =
2248 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2249 let applied = self.apply_materialized_update_for_entity(
2250 query,
2251 entity,
2252 &compiled_plan,
2253 assignments,
2254 )?;
2255 reservations.admit(&applied)?;
2256 touched_ids.push(applied.id);
2257 prepared.push(applied);
2258 }
2259 }
2260 let affected = prepared.len() as u64;
2261 let mut published = 0;
2262 let persistence = (|| -> RedDBResult<()> {
2263 self.enforce_unique_update_post_images(&query.table, &compiled_plan, &prepared)?;
2266 for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2267 if compiled_plan.row_touches_unique_columns
2268 || compiled_plan
2269 .row_contract_plan
2270 .as_ref()
2271 .is_some_and(|plan| plan.has_expressions)
2272 {
2273 let db = self.db();
2274 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2275 for item in chunk {
2276 let fields =
2277 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2278 let previous_id =
2279 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2280 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2281 }
2282 }
2283 self.persist_update_chunk(chunk)?;
2284 published += chunk.len();
2285 }
2286 Ok(())
2287 })();
2288 let published_mutations = &prepared[..published];
2291 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2292 if !query.suppress_events {
2293 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2294 }
2295 persistence?;
2296 drop(reservations);
2297
2298 if affected > 0 {
2299 self.note_table_write(&query.table);
2300 }
2301 if query.claim_limit.is_some() {
2302 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2303 }
2304 record_claim_outcome(
2305 &self.inner.claim_telemetry,
2306 query.claim_limit,
2307 &query.table,
2308 claim_model,
2309 affected,
2310 );
2311
2312 Ok((
2313 RuntimeQueryResult::dml_result(
2314 raw_query.to_string(),
2315 affected,
2316 "update",
2317 "runtime-dml",
2318 ),
2319 touched_ids,
2320 ))
2321 }
2322
2323 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2324 let conn_id = current_connection_id();
2325 let locks = self.inner.pending_claim_locks.read();
2326 ids.into_iter()
2327 .filter(|id| {
2328 let Some(entity) = self.inner.db.store().get(table, *id) else {
2329 return false;
2330 };
2331 let key = (table.to_string(), entity.logical_id());
2332 locks
2333 .get(&key)
2334 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2335 })
2336 .collect()
2337 }
2338
2339 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2340 if self.current_xid().is_none() || ids.is_empty() {
2341 return;
2342 }
2343
2344 let conn_id = current_connection_id();
2345 let store = self.inner.db.store();
2346 let mut locks = self.inner.pending_claim_locks.write();
2347 for id in ids {
2348 let Some(entity) = store.get(table, *id) else {
2349 continue;
2350 };
2351 locks.insert((table.to_string(), entity.logical_id()), conn_id);
2352 }
2353 }
2354
2355 fn execute_update_inner_tracked_locked(
2356 &self,
2357 raw_query: &str,
2358 query: &UpdateQuery,
2359 compiled_plan: &CompiledUpdatePlan,
2360 ids_to_update: &[EntityId],
2361 effective_filter: Option<&Filter>,
2362 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2363 let topology_lock = self
2366 .index_store_ref()
2367 .collection_topology_lock(&query.table);
2368 let topology_guard = topology_lock.read();
2369 let store = self.inner.db.store();
2370 let mut touched_ids = Vec::new();
2371 let mut lock_entries = Vec::new();
2372
2373 for id in ids_to_update {
2374 let Some(candidate) = store.get(&query.table, *id) else {
2375 continue;
2376 };
2377 let logical_id = candidate.logical_id();
2378 let lock_key = format!("row:{}", logical_id.raw());
2379 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2380 lock_entries.push((lock_key, logical_id, rmw_lock));
2381 }
2382
2383 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2384 lock_entries.dedup_by(|left, right| left.0 == right.0);
2385 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2386
2387 let mut applied_chunk = Vec::new();
2388 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2389 for (_, logical_id, _) in &lock_entries {
2390 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2391 else {
2392 continue;
2393 };
2394 if let Some(filter) = effective_filter {
2395 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2396 Some(self.inner.db.as_ref()),
2397 &entity,
2398 filter,
2399 &query.table,
2400 &query.table,
2401 ) {
2402 continue;
2403 }
2404 }
2405
2406 let assignments =
2407 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2408 let applied = self.apply_materialized_update_for_entity(
2409 query,
2410 entity,
2411 compiled_plan,
2412 assignments,
2413 )?;
2414 reservations.admit(&applied)?;
2415 touched_ids.push(applied.id);
2416 applied_chunk.push(applied);
2417 }
2418
2419 let affected = applied_chunk.len() as u64;
2420 if !applied_chunk.is_empty() {
2421 self.enforce_unique_update_post_images(&query.table, compiled_plan, &applied_chunk)?;
2422 self.persist_update_chunk(&applied_chunk)?;
2423 let lsns = self.flush_update_chunk(&applied_chunk, topology_guard)?;
2424 if !query.suppress_events {
2425 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2426 }
2427 }
2428
2429 if affected > 0 {
2430 self.note_table_write(&query.table);
2431 }
2432
2433 Ok((
2434 RuntimeQueryResult::dml_result(
2435 raw_query.to_string(),
2436 affected,
2437 "update",
2438 "runtime-dml",
2439 ),
2440 touched_ids,
2441 ))
2442 }
2443
2444 fn enforce_unique_update_post_images(
2445 &self,
2446 collection: &str,
2447 compiled_plan: &CompiledUpdatePlan,
2448 applied: &[AppliedEntityMutation],
2449 ) -> RedDBResult<()> {
2450 if !compiled_plan.touches_unique_index {
2451 return Ok(());
2452 }
2453 let post_images: Vec<_> = applied
2454 .iter()
2455 .map(|item| {
2456 (
2457 item.replaced_entity.as_ref().map_or(item.id, |old| old.id),
2458 crate::application::ports::entity_row_fields_snapshot(&item.entity),
2459 )
2460 })
2461 .collect();
2462 self.enforce_unique_updates(collection, &post_images)
2463 }
2464
2465 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2466 let mut static_field_assignments = Vec::new();
2467 let mut static_metadata_assignments = Vec::new();
2468 let mut dynamic_assignments = Vec::new();
2469 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2470 let mut row_modified_columns = Vec::new();
2471
2472 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2473 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2474 let metadata_key = resolve_sql_ttl_metadata_key(column);
2475 if compound_op.is_some() && metadata_key.is_some() {
2476 return Err(RedDBError::Query(format!(
2477 "compound assignment is only supported for row fields: {column}"
2478 )));
2479 }
2480 if compound_op.is_none() {
2481 if let Ok(value) = fold_expr_to_value(expr.clone()) {
2482 let unknown_text_target = metadata_key.is_none()
2485 && matches!(value, Value::Text(_))
2486 && row_contract_plan
2487 .as_ref()
2488 .and_then(|plan| plan.declared_rules.get(column))
2489 .is_none();
2490 if !unknown_text_target {
2491 if let Some(metadata_key) = metadata_key {
2492 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2493 let (canonical_key, canonical_value) =
2494 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2495 static_metadata_assignments
2496 .push((canonical_key.to_string(), canonical_value));
2497 } else {
2498 let is_secret_column = row_contract_plan
2499 .as_ref()
2500 .and_then(|plan| plan.declared_rules.get(column))
2501 .is_some_and(|rule| {
2502 rule.data_type == reddb_types::DataType::Secret
2503 });
2504 let value =
2505 self.resolve_secret_column_value(value, is_secret_column)?;
2506 static_field_assignments.push((
2507 column.clone(),
2508 normalize_row_update_assignment_with_plan(
2509 &query.table,
2510 column,
2511 value,
2512 row_contract_plan.as_ref(),
2513 )?,
2514 ));
2515 row_modified_columns.push(column.clone());
2516 }
2517 continue;
2518 }
2519 }
2520 }
2521
2522 dynamic_assignments.push(CompiledUpdateAssignment {
2523 column: column.clone(),
2524 expr: expr.clone(),
2525 compound_op,
2526 metadata_key,
2527 row_rule: if metadata_key.is_none() {
2528 if let Some(plan) = row_contract_plan.as_ref() {
2529 if plan.timestamps_enabled
2530 && (column == "created_at" || column == "updated_at")
2531 {
2532 return Err(RedDBError::Query(format!(
2533 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2534 query.table, column
2535 )));
2536 }
2537 if let Some(rule) = plan.declared_rules.get(column) {
2538 Some(rule.clone())
2539 } else if plan.strict_schema {
2540 return Err(RedDBError::Query(format!(
2541 "collection '{}' is strict and does not allow undeclared fields: {}",
2542 query.table, column
2543 )));
2544 } else {
2545 None
2546 }
2547 } else {
2548 None
2549 }
2550 } else {
2551 None
2552 },
2553 });
2554 if metadata_key.is_none() {
2555 row_modified_columns.push(column.clone());
2556 }
2557 }
2558
2559 let row_modified_columns = dedupe_update_columns(row_modified_columns);
2560 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2561 row_modified_columns.iter().any(|column| {
2562 plan.unique_columns
2563 .keys()
2564 .any(|unique| unique.eq_ignore_ascii_case(column))
2565 })
2566 });
2567
2568 let touches_unique_index = self
2569 .index_store_ref()
2570 .touches_unique_index(&query.table, &row_modified_columns);
2571
2572 if let Some(ttl_ms) = query.ttl_ms {
2573 static_metadata_assignments
2574 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2575 }
2576 if let Some(expires_at_ms) = query.expires_at_ms {
2577 static_metadata_assignments.push((
2578 "_expires_at".to_string(),
2579 metadata_u64_to_value(expires_at_ms),
2580 ));
2581 }
2582 for (key, val) in &query.with_metadata {
2583 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2584 }
2585
2586 Ok(CompiledUpdatePlan {
2587 static_field_assignments,
2588 static_metadata_assignments,
2589 dynamic_assignments,
2590 row_contract_plan,
2591 row_modified_columns,
2592 row_touches_unique_columns,
2593 touches_unique_index,
2594 })
2595 }
2596
2597 fn materialize_update_assignments_for_entity(
2598 &self,
2599 query: &UpdateQuery,
2600 entity: &UnifiedEntity,
2601 compiled_plan: &CompiledUpdatePlan,
2602 ) -> RedDBResult<MaterializedUpdateAssignments> {
2603 let mut assignments = MaterializedUpdateAssignments::default();
2604 let mut record: Option<UnifiedRecord> = None;
2605
2606 for assignment in &compiled_plan.dynamic_assignments {
2607 if assignment.compound_op.is_some()
2608 && !matches!(
2609 entity.data,
2610 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2611 )
2612 {
2613 return Err(RedDBError::Query(format!(
2614 "compound assignment is only supported for row or graph UPDATE column '{}'",
2615 assignment.column
2616 )));
2617 }
2618 if record.is_none() {
2619 record = runtime_any_record_from_entity_ref(entity);
2620 }
2621 let Some(record) = record.as_ref() else {
2622 return Err(RedDBError::Query(format!(
2623 "UPDATE could not materialize runtime record for entity {} in '{}'",
2624 entity.id.raw(),
2625 query.table
2626 )));
2627 };
2628 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2629 Some(self.inner.db.as_ref()),
2630 &assignment.expr,
2631 record,
2632 Some(query.table.as_str()),
2633 Some(query.table.as_str()),
2634 )
2635 .ok_or_else(|| {
2636 RedDBError::Query(format!(
2637 "failed to evaluate UPDATE expression for column '{}'",
2638 assignment.column
2639 ))
2640 })?;
2641 let value = if let Some(op) = assignment.compound_op {
2642 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2643 } else {
2644 rhs
2645 };
2646
2647 let sensitive = super::join_filter::expression_uses_secret(
2648 &assignment.expr,
2649 record,
2650 Some(&query.table),
2651 Some(&query.table),
2652 );
2653 let secret_target = assignment
2654 .row_rule
2655 .as_ref()
2656 .is_some_and(|rule| rule.data_type == reddb_types::DataType::Secret)
2657 || record
2658 .get(&assignment.column)
2659 .is_some_and(|value| matches!(value, Value::Secret(_)));
2660 if sensitive && !secret_target {
2661 return Err(RedDBError::Query(
2662 "secret-derived UPDATE values require a SECRET target column".into(),
2663 ));
2664 }
2665 if let Some(metadata_key) = assignment.metadata_key {
2666 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2667 let (canonical_key, canonical_value) =
2668 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2669 assignments
2670 .dynamic_metadata_assignments
2671 .push((canonical_key.to_string(), canonical_value));
2672 } else {
2673 assignments.dynamic_field_assignments.push((
2674 assignment.column.clone(),
2675 normalize_row_update_value_for_rule(
2676 &query.table,
2677 self.resolve_secret_column_value(
2678 value,
2679 assignment.row_rule.as_ref().is_some_and(|rule| {
2680 rule.data_type == reddb_types::DataType::Secret
2681 }) || record
2682 .get(&assignment.column)
2683 .is_some_and(|value| matches!(value, Value::Secret(_))),
2684 )?,
2685 assignment.row_rule.as_ref(),
2686 )?,
2687 ));
2688 }
2689 }
2690
2691 Ok(assignments)
2692 }
2693
2694 fn apply_materialized_update_for_entity(
2695 &self,
2696 query: &UpdateQuery,
2697 entity: UnifiedEntity,
2698 compiled_plan: &CompiledUpdatePlan,
2699 mut assignments: MaterializedUpdateAssignments,
2700 ) -> RedDBResult<AppliedEntityMutation> {
2701 if matches!(query.target, UpdateTarget::Kv)
2702 && !compiled_plan
2703 .static_field_assignments
2704 .iter()
2705 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2706 && !assignments
2707 .dynamic_field_assignments
2708 .iter()
2709 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2710 {
2711 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2712 .and_then(|record| record.get("key").cloned())
2713 {
2714 assignments
2715 .dynamic_field_assignments
2716 .push(("key".to_string(), key));
2717 }
2718 }
2719 if matches!(entity.data, EntityData::Row(_)) {
2720 return self.apply_loaded_sql_update_row_core(
2721 query.table.clone(),
2722 entity,
2723 &compiled_plan.static_field_assignments,
2724 assignments.dynamic_field_assignments,
2725 &compiled_plan.static_metadata_assignments,
2726 assignments.dynamic_metadata_assignments,
2727 compiled_plan.row_contract_plan.as_ref(),
2728 &compiled_plan.row_modified_columns,
2729 compiled_plan.row_touches_unique_columns,
2730 );
2731 }
2732
2733 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2734
2735 let operations = build_patch_operations_from_materialized_assignments(
2736 &entity,
2737 compiled_plan,
2738 assignments,
2739 );
2740 self.apply_loaded_patch_entity_core(
2741 query.table.clone(),
2742 entity,
2743 crate::json::Value::Null,
2744 operations,
2745 )
2746 }
2747
2748 pub fn execute_delete(
2750 &self,
2751 raw_query: &str,
2752 query: &DeleteQuery,
2753 ) -> RedDBResult<RuntimeQueryResult> {
2754 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2755 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2756 self,
2757 &query.table,
2758 );
2759 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2760 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2763 return Err(RedDBError::InvalidOperation(format!(
2764 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2765 query.table
2766 )));
2767 }
2768 crate::runtime::collection_contract::CollectionContractGate::check(
2772 self,
2773 &query.table,
2774 crate::runtime::collection_contract::MutationKind::Delete,
2775 )?;
2776
2777 if let Some(items) = query.returning.clone() {
2784 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2785 RedDBError::Query(
2786 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2787 )
2788 })?;
2789 let captured = self.execute_query(&select_sql)?;
2790
2791 let mut inner_query = query.clone();
2792 inner_query.returning = None;
2793 let _ = self.execute_delete(raw_query, &inner_query)?;
2794
2795 let snapshots: Vec<Vec<(String, Value)>> = captured
2796 .result
2797 .records
2798 .iter()
2799 .map(|rec| {
2800 rec.iter_fields()
2801 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2802 .collect()
2803 })
2804 .collect();
2805 let affected = snapshots.len() as u64;
2806 let result = build_returning_result(&items, &snapshots, None);
2807
2808 let mut response = RuntimeQueryResult::dml_result(
2809 raw_query.to_string(),
2810 affected,
2811 "delete",
2812 "runtime-dml-returning",
2813 );
2814 response.result = result;
2815 return Ok(response);
2816 }
2817 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2824 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2825 self,
2826 &query.table,
2827 reddb_rql::ast::PolicyAction::Delete,
2828 );
2829 let Some(policy) = rls_filter else {
2830 return Ok(RuntimeQueryResult::dml_result(
2831 raw_query.to_string(),
2832 0,
2833 "delete",
2834 "runtime-dml-rls",
2835 ));
2836 };
2837 let mut augmented = query.clone();
2842 augmented.filter = Some(match augmented.filter.take() {
2843 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2844 None => policy,
2845 });
2846 return self.execute_delete_inner(raw_query, &augmented);
2847 }
2848 self.execute_delete_inner(raw_query, query)
2849 }
2850
2851 fn execute_delete_inner(
2852 &self,
2853 raw_query: &str,
2854 query: &DeleteQuery,
2855 ) -> RedDBResult<RuntimeQueryResult> {
2856 let topology_lock = self
2857 .index_store_ref()
2858 .collection_topology_lock(&query.table);
2859 let effective_filter = effective_delete_filter(query);
2860
2861 let scan = super::dml_target_scan::DmlTargetScan::new(
2865 self,
2866 &query.table,
2867 effective_filter.as_ref(),
2868 None,
2869 );
2870 let ids_to_delete = {
2871 let _topology_guard = topology_lock.read();
2872 scan.find_target_ids()?
2873 };
2874
2875 let needs_delete_events =
2878 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2879 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2880 scan.row_json_pre_images(&ids_to_delete)
2881 } else {
2882 HashMap::new()
2883 };
2884
2885 let mut affected: u64 = 0;
2886 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2887 let (count, lsns) =
2888 self.delete_entities_batch(&query.table, chunk, topology_lock.read())?;
2889 #[cfg(test)]
2890 self.index_store_ref().mutation_test_hook(
2891 &query.table,
2892 super::index_store::MutationTestPhase::BeforeEvents,
2893 );
2894 affected += count;
2895 if needs_delete_events && !lsns.is_empty() {
2896 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2900 self.emit_delete_events_for_collection(
2901 &query.table,
2902 deleted_chunk,
2903 &lsns,
2904 &pre_images,
2905 )?;
2906 }
2907 }
2908 pre_images.clear();
2909
2910 if affected > 0 {
2911 self.note_table_write(&query.table);
2912 }
2913
2914 Ok(RuntimeQueryResult::dml_result(
2915 raw_query.to_string(),
2916 affected,
2917 "delete",
2918 "runtime-dml",
2919 ))
2920 }
2921}
2922
2923#[cfg(test)]
2924mod tests {
2925 use crate::storage::wal::{WalReader, WalRecord};
2926 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2927 use crate::{RedDBOptions, RedDBRuntime};
2928 use reddb_types::Value;
2929 use std::path::Path;
2930
2931 fn persistent_operational_options(path: &Path) -> RedDBOptions {
2932 RedDBOptions::persistent(path)
2933 .with_storage_profile(StorageProfileSelection {
2934 deploy_profile: DeployProfile::Embedded,
2935 packaging: StoragePackaging::OperationalDirectory,
2936 replica_count: 0,
2937 managed_backup: false,
2938 wal_retention: false,
2939 })
2940 .unwrap()
2941 }
2942
2943 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
2944 WalReader::open(wal_path)
2945 .expect("wal opens")
2946 .iter()
2947 .map(|record| record.expect("wal record decodes").1)
2948 .filter_map(|record| match record {
2949 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
2950 _ => None,
2951 })
2952 .collect()
2953 }
2954
2955 fn action_contains_text(action: &[u8], needle: &str) -> bool {
2956 action
2957 .windows(needle.len())
2958 .any(|window| window == needle.as_bytes())
2959 }
2960
2961 fn claim_metric_count(
2962 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
2963 metric: &str,
2964 collection: &str,
2965 model: &str,
2966 ) -> u64 {
2967 let rows = match metric {
2968 "attempts" => &snapshot.attempts,
2969 "successful" => &snapshot.successful,
2970 "misses" => &snapshot.misses,
2971 "skipped_locked" => &snapshot.skipped_locked,
2972 other => panic!("unknown claim metric {other}"),
2973 };
2974 rows.iter()
2975 .find(|((actual_collection, actual_model), _)| {
2976 actual_collection == collection && actual_model == model
2977 })
2978 .map(|(_, count)| *count)
2979 .unwrap_or(0)
2980 }
2981
2982 fn assert_statement_writes_collections_in_one_new_wal_batch(
2983 rt: &RedDBRuntime,
2984 wal_path: &Path,
2985 statement: &str,
2986 source: &str,
2987 event_queue: &str,
2988 ) {
2989 let before_batches = store_commit_batches(wal_path).len();
2990
2991 rt.execute_query(statement).unwrap();
2992
2993 let batches = store_commit_batches(wal_path);
2994 let statement_batches = &batches[before_batches..];
2995 let source_batch = statement_batches
2996 .iter()
2997 .position(|actions| {
2998 actions.iter().any(|action| {
2999 action_contains_text(action, source)
3000 && !action_contains_text(action, event_queue)
3001 })
3002 })
3003 .expect("source collection write batch is present");
3004 let event_batch = statement_batches
3005 .iter()
3006 .position(|actions| {
3007 actions
3008 .iter()
3009 .any(|action| action_contains_text(action, event_queue))
3010 })
3011 .expect("event queue write batch is present");
3012
3013 assert_eq!(
3014 source_batch, event_batch,
3015 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
3016 );
3017 }
3018
3019 #[test]
3020 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
3021 let dir = tempfile::tempdir().unwrap();
3022 let db_path = dir.path().join("events_dual_write.rdb");
3023 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3024 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3025
3026 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3027 .unwrap();
3028 assert_statement_writes_collections_in_one_new_wal_batch(
3029 &rt,
3030 &wal_path,
3031 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
3032 "users",
3033 "users_events",
3034 );
3035 }
3036
3037 #[test]
3038 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
3039 let dir = tempfile::tempdir().unwrap();
3040 let db_path = dir.path().join("events_update_atomic.rdb");
3041 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3042 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3043
3044 rt.execute_query(
3045 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
3046 )
3047 .unwrap();
3048 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3049 .unwrap();
3050
3051 assert_statement_writes_collections_in_one_new_wal_batch(
3052 &rt,
3053 &wal_path,
3054 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
3055 "users",
3056 "user_updates",
3057 );
3058 }
3059
3060 #[test]
3061 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
3062 let dir = tempfile::tempdir().unwrap();
3063 let db_path = dir.path().join("events_delete_atomic.rdb");
3064 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3065 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3066
3067 rt.execute_query(
3068 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
3069 )
3070 .unwrap();
3071 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3072 .unwrap();
3073
3074 assert_statement_writes_collections_in_one_new_wal_batch(
3075 &rt,
3076 &wal_path,
3077 "DELETE FROM users WHERE id = 1",
3078 "users",
3079 "user_deletes",
3080 );
3081 }
3082
3083 #[test]
3084 fn update_where_id_in_with_hash_index_updates_expected_rows() {
3085 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3086 rt.execute_query("CREATE TABLE users (id INT, score INT)")
3087 .unwrap();
3088 for id in 0..5 {
3089 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
3090 .unwrap();
3091 }
3092 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
3093 .unwrap();
3094
3095 let updated = rt
3096 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
3097 .unwrap();
3098 assert_eq!(updated.affected_rows, 3);
3099
3100 let selected = rt
3101 .execute_query("SELECT id, score FROM users ORDER BY id")
3102 .unwrap();
3103 let scores: Vec<(i64, i64)> = selected
3104 .result
3105 .records
3106 .iter()
3107 .map(|record| {
3108 let id = match record.get("id").unwrap() {
3109 Value::Integer(value) => *value,
3110 other => panic!("expected integer id, got {other:?}"),
3111 };
3112 let score = match record.get("score").unwrap() {
3113 Value::Integer(value) => *value,
3114 other => panic!("expected integer score, got {other:?}"),
3115 };
3116 (id, score)
3117 })
3118 .collect();
3119 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3120 }
3121
3122 #[test]
3129 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3130 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3131 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3132 .unwrap();
3133 for id in 0..5 {
3134 rt.execute_query(&format!(
3135 "INSERT INTO items (id, score) VALUES ({id}, {})",
3136 id * 10
3137 ))
3138 .unwrap();
3139 }
3140 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3141 .unwrap();
3142
3143 let updated_one = rt
3147 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3148 .unwrap();
3149 assert_eq!(updated_one.affected_rows, 1);
3150
3151 let updated_many = rt
3155 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3156 .unwrap();
3157 assert_eq!(updated_many.affected_rows, 2);
3158
3159 let snapshot = rt
3160 .execute_query("SELECT id, score FROM items ORDER BY id")
3161 .unwrap();
3162 let pairs: Vec<(i64, i64)> = snapshot
3163 .result
3164 .records
3165 .iter()
3166 .map(|record| {
3167 let id = match record.get("id").unwrap() {
3168 Value::Integer(value) => *value,
3169 other => panic!("expected integer id, got {other:?}"),
3170 };
3171 let score = match record.get("score").unwrap() {
3172 Value::Integer(value) => *value,
3173 other => panic!("expected integer score, got {other:?}"),
3174 };
3175 (id, score)
3176 })
3177 .collect();
3178 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3179
3180 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3182 assert_eq!(updated_all.affected_rows, 5);
3183 let after = rt
3184 .execute_query("SELECT score FROM items ORDER BY id")
3185 .unwrap();
3186 let scores: Vec<i64> = after
3187 .result
3188 .records
3189 .iter()
3190 .map(|record| match record.get("score").unwrap() {
3191 Value::Integer(value) => *value,
3192 other => panic!("expected integer score, got {other:?}"),
3193 })
3194 .collect();
3195 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3196 }
3197
3198 #[test]
3204 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3205 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3206 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3207 .unwrap();
3208 for id in 0..5 {
3209 rt.execute_query(&format!(
3210 "INSERT INTO items (id, score) VALUES ({id}, {})",
3211 id * 10
3212 ))
3213 .unwrap();
3214 }
3215 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3216 .unwrap();
3217
3218 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3221 assert_eq!(deleted_one.affected_rows, 1);
3222
3223 let deleted_many = rt
3227 .execute_query("DELETE FROM items WHERE score > 25")
3228 .unwrap();
3229 assert_eq!(deleted_many.affected_rows, 2);
3230
3231 let surviving = rt
3232 .execute_query("SELECT id FROM items ORDER BY id")
3233 .unwrap();
3234 let ids: Vec<i64> = surviving
3235 .result
3236 .records
3237 .iter()
3238 .map(|record| match record.get("id").unwrap() {
3239 Value::Integer(value) => *value,
3240 other => panic!("expected integer id, got {other:?}"),
3241 })
3242 .collect();
3243 assert_eq!(ids, vec![0, 1]);
3244
3245 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3247 assert_eq!(deleted_rest.affected_rows, 2);
3248 let empty = rt.execute_query("SELECT id FROM items").unwrap();
3249 assert!(empty.result.records.is_empty());
3250 }
3251
3252 #[test]
3257 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3258 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3259 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3260 .unwrap();
3261
3262 let inserted = rt
3265 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3266 .unwrap();
3267 assert_eq!(inserted.affected_rows, 1);
3268
3269 let update_err = rt
3271 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3272 .unwrap_err();
3273 let msg = format!("{update_err}");
3274 assert!(
3275 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3276 "expected UPDATE rejection message, got: {msg}"
3277 );
3278
3279 let delete_err = rt
3281 .execute_query("DELETE FROM events WHERE id = 1")
3282 .unwrap_err();
3283 let msg = format!("{delete_err}");
3284 assert!(
3285 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3286 "expected DELETE rejection message, got: {msg}"
3287 );
3288
3289 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3292 assert_eq!(surviving.result.records.len(), 1);
3293 }
3294
3295 #[test]
3299 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3300 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3301 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3302 .unwrap();
3303
3304 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3305 .unwrap();
3306 let updated = rt
3307 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3308 .unwrap();
3309 assert_eq!(updated.affected_rows, 1);
3310 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3311 assert_eq!(deleted.affected_rows, 1);
3312 }
3313
3314 #[test]
3315 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3316 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3317 rt.execute_query(
3318 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3319 )
3320 .unwrap();
3321
3322 let inserted = rt
3323 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3324 .unwrap();
3325 assert_eq!(inserted.affected_rows, 1);
3326
3327 let events = queue_payloads(&rt, "audit_log");
3328 assert_eq!(events.len(), 1);
3329 let event = events[0].as_object().expect("event payload object");
3330 assert!(event
3331 .get("event_id")
3332 .and_then(crate::json::Value::as_str)
3333 .is_some_and(|value| !value.is_empty()));
3334 assert_eq!(
3335 event.get("op").and_then(crate::json::Value::as_str),
3336 Some("insert")
3337 );
3338 assert_eq!(
3339 event.get("collection").and_then(crate::json::Value::as_str),
3340 Some("users")
3341 );
3342 assert_eq!(
3343 event.get("id").and_then(crate::json::Value::as_u64),
3344 Some(7)
3345 );
3346 assert!(event
3347 .get("ts")
3348 .and_then(crate::json::Value::as_u64)
3349 .is_some());
3350 assert!(event
3351 .get("lsn")
3352 .and_then(crate::json::Value::as_u64)
3353 .is_some());
3354 assert!(matches!(
3355 event.get("tenant"),
3356 Some(crate::json::Value::Null)
3357 ));
3358 assert!(matches!(
3359 event.get("before"),
3360 Some(crate::json::Value::Null)
3361 ));
3362 let after = event
3363 .get("after")
3364 .and_then(crate::json::Value::as_object)
3365 .expect("after object");
3366 assert_eq!(
3367 after.get("id").and_then(crate::json::Value::as_u64),
3368 Some(7)
3369 );
3370 assert_eq!(
3371 after.get("email").and_then(crate::json::Value::as_str),
3372 Some("a@example.com")
3373 );
3374 }
3375
3376 #[test]
3377 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3378 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3379 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3380 .unwrap();
3381
3382 rt.execute_query(
3383 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
3384 )
3385 .unwrap();
3386
3387 let events = queue_payloads(&rt, "users_events");
3388 assert_eq!(events.len(), 2);
3389 let mut previous_lsn = 0;
3390 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
3391 let object = event.as_object().expect("event payload object");
3392 assert_eq!(
3393 object.get("op").and_then(crate::json::Value::as_str),
3394 Some("insert")
3395 );
3396 assert_eq!(
3397 object.get("id").and_then(crate::json::Value::as_u64),
3398 Some(expected_id)
3399 );
3400 let lsn = object
3401 .get("lsn")
3402 .and_then(crate::json::Value::as_u64)
3403 .expect("event lsn");
3404 assert!(
3405 lsn > previous_lsn,
3406 "event LSNs should increase in row order"
3407 );
3408 previous_lsn = lsn;
3409 let after = object
3410 .get("after")
3411 .and_then(crate::json::Value::as_object)
3412 .expect("after object");
3413 assert_eq!(
3414 after.get("id").and_then(crate::json::Value::as_u64),
3415 Some(expected_id)
3416 );
3417 }
3418 }
3419
3420 #[test]
3421 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
3422 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3423 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
3424 .expect("create table");
3425 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
3427 .expect("create index");
3428 rt.execute_query(
3429 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
3430 (1, 10, 'ready'), (2, 20, 'ready')",
3431 )
3432 .expect("insert rows");
3433
3434 let claim_lock = rt
3435 .inner
3436 .rmw_locks
3437 .lock_for("claim_metric_locked", "__table_claim_update__");
3438 let _guard = claim_lock.lock();
3439 let updated = rt
3440 .execute_query(
3441 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
3442 CLAIM LIMIT 2 ORDER BY rank ASC",
3443 )
3444 .expect("claim skips locked candidates");
3445
3446 assert_eq!(updated.affected_rows, 0);
3447 let snapshot = rt.claim_telemetry_snapshot();
3448 assert_eq!(
3449 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
3450 1
3451 );
3452 assert_eq!(
3453 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
3454 2
3455 );
3456 assert_eq!(
3457 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
3458 0
3459 );
3460 assert_eq!(
3461 snapshot.skipped_locked.len(),
3462 1,
3463 "skipped-lock labels stay bounded to collection/model"
3464 );
3465 }
3466
3467 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3468 let result = rt
3469 .execute_query(&format!("QUEUE PEEK {queue} 10"))
3470 .expect("peek queue");
3471 result
3472 .result
3473 .records
3474 .iter()
3475 .map(
3476 |record| match record.get("payload").expect("payload column") {
3477 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3478 other => panic!("expected JSON queue payload, got {other:?}"),
3479 },
3480 )
3481 .collect()
3482 }
3483
3484 #[test]
3496 fn auto_index_id_fires_on_first_insert() {
3497 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3498 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
3499 .unwrap();
3500
3501 assert!(
3503 rt.index_store_ref()
3504 .find_index_for_column("bench_users", "id")
3505 .is_none(),
3506 "freshly created collection should not have an `id` index"
3507 );
3508
3509 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
3511 .unwrap();
3512
3513 let registered = rt
3515 .index_store_ref()
3516 .find_index_for_column("bench_users", "id")
3517 .expect("auto-index hook should have registered idx_id on first insert");
3518 assert_eq!(registered.name, "idx_id");
3519 assert_eq!(registered.collection, "bench_users");
3520 assert_eq!(registered.columns, vec!["id".to_string()]);
3521 assert!(matches!(
3522 registered.method,
3523 super::super::index_store::IndexMethodKind::Hash
3524 ));
3525
3526 for id in 2..=5 {
3529 rt.execute_query(&format!(
3530 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
3531 id * 10
3532 ))
3533 .unwrap();
3534 }
3535 for id in 1..=5 {
3536 let result = rt
3537 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
3538 .unwrap();
3539 assert_eq!(
3540 result.result.records.len(),
3541 1,
3542 "id={id} should match one row"
3543 );
3544 }
3545
3546 let deleted = rt
3551 .execute_query("DELETE FROM bench_users WHERE id = 3")
3552 .unwrap();
3553 assert_eq!(deleted.affected_rows, 1);
3554 }
3555
3556 #[test]
3561 fn auto_index_id_fires_on_first_bulk_insert() {
3562 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3563 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
3564 .unwrap();
3565
3566 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
3567 .unwrap();
3568
3569 let registered = rt
3570 .index_store_ref()
3571 .find_index_for_column("bench_bulk", "id")
3572 .expect("auto-index hook should fire on first bulk insert");
3573 assert_eq!(registered.name, "idx_id");
3574
3575 for id in 1..=3 {
3577 let result = rt
3578 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
3579 .unwrap();
3580 assert_eq!(result.result.records.len(), 1);
3581 }
3582 }
3583
3584 #[test]
3588 fn auto_index_id_skips_when_no_id_column() {
3589 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3590 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
3591 .unwrap();
3592 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
3593 .unwrap();
3594
3595 assert!(rt
3596 .index_store_ref()
3597 .find_index_for_column("plain", "id")
3598 .is_none());
3599 assert!(rt
3600 .index_store_ref()
3601 .find_index_for_column("plain", "uid")
3602 .is_none());
3603 }
3604
3605 #[test]
3610 fn auto_index_id_skips_when_index_already_exists() {
3611 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3612 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
3613 .unwrap();
3614 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
3616 .unwrap();
3617 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
3618 .unwrap();
3619
3620 let registered = rt
3621 .index_store_ref()
3622 .find_index_for_column("pre", "id")
3623 .expect("user index should still be there");
3624 assert_eq!(
3625 registered.name, "user_idx",
3626 "auto-index hook must not overwrite an existing index"
3627 );
3628 }
3629
3630 #[test]
3634 fn auto_index_id_dropped_with_collection() {
3635 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3636 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
3637 .unwrap();
3638 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
3639 .unwrap();
3640 assert!(rt
3641 .index_store_ref()
3642 .find_index_for_column("ephemeral", "id")
3643 .is_some());
3644
3645 rt.execute_query("DROP TABLE ephemeral").unwrap();
3646
3647 assert!(
3648 rt.index_store_ref()
3649 .find_index_for_column("ephemeral", "id")
3650 .is_none(),
3651 "implicit `idx_id` must be reaped when its collection drops"
3652 );
3653 }
3654
3655 #[test]
3660 fn auto_index_id_disabled_by_config() {
3661 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
3662 let rt = RedDBRuntime::with_options(opts).unwrap();
3663
3664 rt.execute_query("CREATE TABLE off (id INT, score INT)")
3665 .unwrap();
3666 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
3667 .unwrap();
3668
3669 assert!(
3670 rt.index_store_ref()
3671 .find_index_for_column("off", "id")
3672 .is_none(),
3673 "with auto_index_id=false, no implicit index should be created"
3674 );
3675 }
3676
3677 #[test]
3680 fn update_single_row_emits_update_event() {
3681 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3682 rt.execute_query(
3683 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
3684 )
3685 .unwrap();
3686 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3687 .unwrap();
3688
3689 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3690 .unwrap();
3691
3692 let events = queue_payloads(&rt, "audit_log");
3693 assert_eq!(events.len(), 1, "expected exactly 1 update event");
3694 let event = events[0].as_object().expect("event payload object");
3695 assert_eq!(
3696 event.get("op").and_then(crate::json::Value::as_str),
3697 Some("update")
3698 );
3699 assert_eq!(
3700 event.get("collection").and_then(crate::json::Value::as_str),
3701 Some("users")
3702 );
3703 assert!(event
3704 .get("event_id")
3705 .and_then(crate::json::Value::as_str)
3706 .is_some_and(|v| !v.is_empty()));
3707 let before = event
3708 .get("before")
3709 .and_then(crate::json::Value::as_object)
3710 .expect("before must be an object");
3711 let after = event
3712 .get("after")
3713 .and_then(crate::json::Value::as_object)
3714 .expect("after must be an object");
3715 assert_eq!(
3716 before.get("name").and_then(crate::json::Value::as_str),
3717 Some("Alice"),
3718 "before.name should be the old value"
3719 );
3720 assert_eq!(
3721 after.get("name").and_then(crate::json::Value::as_str),
3722 Some("Bob"),
3723 "after.name should be the new value"
3724 );
3725 }
3726
3727 #[test]
3728 fn update_event_only_includes_changed_fields() {
3729 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3730 rt.execute_query(
3731 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
3732 )
3733 .unwrap();
3734 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
3735 .unwrap();
3736
3737 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3738 .unwrap();
3739
3740 let events = queue_payloads(&rt, "evts");
3741 assert_eq!(events.len(), 1);
3742 let event = events[0].as_object().unwrap();
3743 let before = event
3744 .get("before")
3745 .and_then(crate::json::Value::as_object)
3746 .unwrap();
3747 let after = event
3748 .get("after")
3749 .and_then(crate::json::Value::as_object)
3750 .unwrap();
3751 assert!(
3753 before.contains_key("name"),
3754 "before must include changed field"
3755 );
3756 assert!(
3757 after.contains_key("name"),
3758 "after must include changed field"
3759 );
3760 assert!(
3762 !before.contains_key("email"),
3763 "before must not include unchanged email"
3764 );
3765 assert!(
3766 !after.contains_key("email"),
3767 "after must not include unchanged email"
3768 );
3769 }
3770
3771 #[test]
3772 fn multi_row_update_emits_one_event_per_row() {
3773 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3774 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
3775 .unwrap();
3776 rt.execute_query(
3777 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
3778 )
3779 .unwrap();
3780
3781 rt.execute_query("UPDATE items SET status = 'done'")
3782 .unwrap();
3783
3784 let events = queue_payloads(&rt, "evts");
3785 assert_eq!(events.len(), 3, "expected one update event per row");
3786 for event in &events {
3787 let obj = event.as_object().unwrap();
3788 assert_eq!(
3789 obj.get("op").and_then(crate::json::Value::as_str),
3790 Some("update")
3791 );
3792 }
3793 }
3794
3795 #[test]
3796 fn delete_single_row_emits_delete_event() {
3797 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3798 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
3799 .unwrap();
3800 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
3801 .unwrap();
3802
3803 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
3804
3805 let events = queue_payloads(&rt, "del_log");
3806 assert_eq!(events.len(), 1);
3807 let event = events[0].as_object().expect("event payload object");
3808 assert_eq!(
3809 event.get("op").and_then(crate::json::Value::as_str),
3810 Some("delete")
3811 );
3812 assert_eq!(
3813 event.get("collection").and_then(crate::json::Value::as_str),
3814 Some("users")
3815 );
3816 assert!(event
3817 .get("event_id")
3818 .and_then(crate::json::Value::as_str)
3819 .is_some_and(|v| !v.is_empty()));
3820 let before = event
3821 .get("before")
3822 .and_then(crate::json::Value::as_object)
3823 .expect("before must be an object for delete");
3824 assert_eq!(
3825 before.get("id").and_then(crate::json::Value::as_u64),
3826 Some(42)
3827 );
3828 assert_eq!(
3829 before.get("name").and_then(crate::json::Value::as_str),
3830 Some("Alice")
3831 );
3832 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
3833 }
3834
3835 #[test]
3836 fn multi_row_delete_emits_one_event_per_row() {
3837 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3838 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
3839 .unwrap();
3840 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
3841 .unwrap();
3842
3843 rt.execute_query("DELETE FROM items").unwrap();
3844
3845 let events = queue_payloads(&rt, "del_log");
3846 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
3847 for event in &events {
3848 let obj = event.as_object().unwrap();
3849 assert_eq!(
3850 obj.get("op").and_then(crate::json::Value::as_str),
3851 Some("delete")
3852 );
3853 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
3854 }
3855 }
3856
3857 #[test]
3858 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
3859 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3860 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
3861 .unwrap();
3862
3863 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3864 .unwrap();
3865 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3866
3867 let events = queue_payloads(&rt, "evts");
3868 assert!(
3869 events.is_empty(),
3870 "UPDATE-only filter must not emit INSERT or DELETE events"
3871 );
3872 }
3873
3874 #[test]
3877 fn suppress_events_on_insert_emits_no_events() {
3878 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3879 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3880 .unwrap();
3881
3882 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3883 .unwrap();
3884
3885 let events = queue_payloads(&rt, "evts");
3886 assert!(
3887 events.is_empty(),
3888 "SUPPRESS EVENTS must prevent INSERT events"
3889 );
3890 }
3891
3892 #[test]
3893 fn suppress_events_on_update_emits_no_events() {
3894 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3895 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3896 .unwrap();
3897 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3898 .unwrap();
3899 let _ = queue_payloads(&rt, "evts");
3901 rt.execute_query("QUEUE PURGE evts").unwrap();
3903
3904 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
3905 .unwrap();
3906
3907 let events = queue_payloads(&rt, "evts");
3908 assert!(
3909 events.is_empty(),
3910 "SUPPRESS EVENTS must prevent UPDATE events"
3911 );
3912 }
3913
3914 #[test]
3915 fn suppress_events_on_delete_emits_no_events() {
3916 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3917 rt.execute_query(
3918 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
3919 )
3920 .unwrap();
3921 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3922 .unwrap();
3923
3924 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
3925 .unwrap();
3926
3927 let events = queue_payloads(&rt, "evts");
3928 assert!(
3929 events.is_empty(),
3930 "SUPPRESS EVENTS must prevent DELETE events"
3931 );
3932 }
3933
3934 #[test]
3935 fn normal_insert_after_suppress_still_emits() {
3936 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3937 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3938 .unwrap();
3939
3940 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3941 .unwrap();
3942 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
3943 .unwrap();
3944
3945 let events = queue_payloads(&rt, "evts");
3946 assert_eq!(
3947 events.len(),
3948 1,
3949 "only the non-suppressed INSERT should emit"
3950 );
3951 assert_eq!(
3952 events[0].get("id").and_then(crate::json::Value::as_u64),
3953 Some(2)
3954 );
3955 }
3956}