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 autocommit_table_update =
2117 matches!(query.target, UpdateTarget::Rows) && self.current_xid().is_none();
2118 let needs_rmw_lock = autocommit_table_update || update_needs_rmw_lock(query);
2119 let claim_model = update_model_name(update_target_model(query.target));
2120 if query.claim_limit.is_some() {
2121 self.inner
2122 .claim_telemetry
2123 .record_attempt(&query.table, claim_model);
2124 }
2125 let claim_lock = query.claim_limit.map(|_| {
2126 self.inner
2127 .rmw_locks
2128 .lock_for(&query.table, "__table_claim_update__")
2129 });
2130 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2131 let Some(guard) = lock.try_lock() else {
2132 let skipped_locked = query.claim_limit.unwrap_or(1);
2133 self.inner.claim_telemetry.record_skipped_locked(
2134 &query.table,
2135 claim_model,
2136 skipped_locked,
2137 );
2138 tracing::debug!(
2139 target: "reddb::claim",
2140 collection = %query.table,
2141 model = claim_model,
2142 skipped_locked,
2143 "concurrent claim skipped locked candidates"
2144 );
2145 return Ok((
2146 RuntimeQueryResult::dml_result(
2147 raw_query.to_string(),
2148 0,
2149 "update",
2150 "runtime-dml",
2151 ),
2152 Vec::new(),
2153 ));
2154 };
2155 Some(guard)
2156 } else {
2157 None
2158 };
2159 let table_rmw_lock = if needs_rmw_lock {
2160 Some(
2161 self.inner
2162 .rmw_locks
2163 .lock_for(&query.table, "__table_rmw_update__"),
2164 )
2165 } else {
2166 None
2167 };
2168 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2169 let mut touched_ids: Vec<EntityId> = Vec::new();
2170 let claim_cap = query.claim_limit.map(|limit| limit as usize);
2171 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2172 let manager = store
2173 .get_collection(&query.table)
2174 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2175 let scan_limit = if query.order_by.is_empty() {
2176 limit_cap
2177 } else {
2178 None
2179 };
2180 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2181 self,
2182 &query.table,
2183 effective_filter.as_ref(),
2184 scan_limit,
2185 query.target,
2186 );
2187 if needs_rmw_lock && !autocommit_table_update {
2188 target_scan = target_scan.with_live_table_rows();
2189 }
2190 let ids_to_update = {
2191 let _topology_guard = topology_lock.read();
2192 let _fresh_snapshot = if autocommit_table_update {
2198 let mut ctx =
2199 crate::runtime::impl_core::capture_current_snapshot().unwrap_or_else(|| {
2200 let manager = self.snapshot_manager();
2201 crate::runtime::impl_core::SnapshotContext {
2202 snapshot: manager.fresh_read_snapshot(),
2203 manager,
2204 own_xids: std::collections::HashSet::new(),
2205 requires_index_fallback: false,
2206 serializable_reader: None,
2207 }
2208 });
2209 ctx.snapshot = ctx.manager.fresh_read_snapshot();
2210 ctx.requires_index_fallback |= !ctx.snapshot.in_progress.is_empty();
2211 Some(crate::runtime::impl_core::CurrentSnapshotGuard::install(
2212 ctx,
2213 ))
2214 } else {
2215 None
2216 };
2217 target_scan.find_target_ids()?
2218 };
2219 let order_limit = if query.claim_limit.is_some() {
2220 None
2221 } else {
2222 limit_cap
2223 };
2224 let ids_to_update = if query.order_by.is_empty() {
2225 ids_to_update
2226 } else {
2227 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
2228 };
2229 let mut ids_to_update = if query.claim_limit.is_some() || autocommit_table_update {
2230 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
2231 } else {
2232 ids_to_update
2233 };
2234 if let Some(claim_cap) = claim_cap {
2235 ids_to_update.truncate(claim_cap);
2236 }
2237 if query.claim_exact
2238 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2239 {
2240 self.inner
2241 .claim_telemetry
2242 .record_miss(&query.table, claim_model);
2243 return Ok((
2244 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2245 Vec::new(),
2246 ));
2247 }
2248
2249 if needs_rmw_lock {
2250 let result = self.execute_update_inner_tracked_locked(
2251 raw_query,
2252 query,
2253 &compiled_plan,
2254 &ids_to_update,
2255 effective_filter.as_ref(),
2256 )?;
2257 if query.claim_limit.is_some() {
2258 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2259 }
2260 record_claim_outcome(
2261 &self.inner.claim_telemetry,
2262 query.claim_limit,
2263 &query.table,
2264 claim_model,
2265 result.0.affected_rows,
2266 );
2267 return Ok(result);
2268 }
2269
2270 let topology_guard = topology_lock.read();
2274 let mut prepared = Vec::new();
2275 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2276 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2277 for entity in manager.get_many(chunk).into_iter().flatten() {
2278 let assignments =
2279 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2280 let applied = self.apply_materialized_update_for_entity(
2281 query,
2282 entity,
2283 &compiled_plan,
2284 assignments,
2285 )?;
2286 reservations.admit(&applied)?;
2287 touched_ids.push(applied.id);
2288 prepared.push(applied);
2289 }
2290 }
2291 let affected = prepared.len() as u64;
2292 let mut published = 0;
2293 let persistence = (|| -> RedDBResult<()> {
2294 self.enforce_unique_update_post_images(&query.table, &compiled_plan, &prepared)?;
2297 for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2298 if compiled_plan.row_touches_unique_columns
2299 || compiled_plan
2300 .row_contract_plan
2301 .as_ref()
2302 .is_some_and(|plan| plan.has_expressions)
2303 {
2304 let db = self.db();
2305 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2306 for item in chunk {
2307 let fields =
2308 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2309 let previous_id =
2310 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2311 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2312 }
2313 }
2314 self.persist_update_chunk(chunk)?;
2315 published += chunk.len();
2316 }
2317 Ok(())
2318 })();
2319 let published_mutations = &prepared[..published];
2322 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2323 if !query.suppress_events {
2324 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2325 }
2326 persistence?;
2327 drop(reservations);
2328
2329 if affected > 0 {
2330 self.note_table_write(&query.table);
2331 }
2332 if query.claim_limit.is_some() {
2333 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2334 }
2335 record_claim_outcome(
2336 &self.inner.claim_telemetry,
2337 query.claim_limit,
2338 &query.table,
2339 claim_model,
2340 affected,
2341 );
2342
2343 Ok((
2344 RuntimeQueryResult::dml_result(
2345 raw_query.to_string(),
2346 affected,
2347 "update",
2348 "runtime-dml",
2349 ),
2350 touched_ids,
2351 ))
2352 }
2353
2354 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2355 let conn_id = current_connection_id();
2356 let locks = self.inner.pending_claim_locks.read();
2357 if locks.is_empty() {
2358 return ids;
2359 }
2360 ids.into_iter()
2361 .filter(|id| {
2362 let Some(entity) = self.inner.db.store().get(table, *id) else {
2363 return false;
2364 };
2365 let key = (table.to_string(), entity.logical_id());
2366 locks
2367 .get(&key)
2368 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2369 })
2370 .collect()
2371 }
2372
2373 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2374 if self.current_xid().is_none() || ids.is_empty() {
2375 return;
2376 }
2377
2378 let conn_id = current_connection_id();
2379 let store = self.inner.db.store();
2380 let mut locks = self.inner.pending_claim_locks.write();
2381 for id in ids {
2382 let Some(entity) = store.get(table, *id) else {
2383 continue;
2384 };
2385 locks.insert((table.to_string(), entity.logical_id()), conn_id);
2386 }
2387 }
2388
2389 fn execute_update_inner_tracked_locked(
2390 &self,
2391 raw_query: &str,
2392 query: &UpdateQuery,
2393 compiled_plan: &CompiledUpdatePlan,
2394 ids_to_update: &[EntityId],
2395 effective_filter: Option<&Filter>,
2396 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2397 let topology_lock = self
2400 .index_store_ref()
2401 .collection_topology_lock(&query.table);
2402 let topology_guard = topology_lock.read();
2403 let store = self.inner.db.store();
2404 let mut touched_ids = Vec::new();
2405 let mut lock_entries = Vec::new();
2406
2407 for id in ids_to_update {
2408 let Some(candidate) = store.get(&query.table, *id) else {
2409 continue;
2410 };
2411 let logical_id = candidate.logical_id();
2412 let lock_key = format!("row:{}", logical_id.raw());
2413 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2414 lock_entries.push((lock_key, logical_id, rmw_lock));
2415 }
2416
2417 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2418 lock_entries.dedup_by(|left, right| left.0 == right.0);
2419 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2420
2421 let mut applied_chunk = Vec::new();
2422 let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2423 for (_, logical_id, _) in &lock_entries {
2424 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2425 else {
2426 continue;
2427 };
2428 if let Some(filter) = effective_filter {
2429 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2430 Some(self.inner.db.as_ref()),
2431 &entity,
2432 filter,
2433 &query.table,
2434 &query.table,
2435 ) {
2436 continue;
2437 }
2438 }
2439
2440 let assignments =
2441 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2442 let applied = self.apply_materialized_update_for_entity(
2443 query,
2444 entity,
2445 compiled_plan,
2446 assignments,
2447 )?;
2448 reservations.admit(&applied)?;
2449 touched_ids.push(applied.id);
2450 applied_chunk.push(applied);
2451 }
2452
2453 let affected = applied_chunk.len() as u64;
2454 let mut published = 0;
2455 let persistence = (|| -> RedDBResult<()> {
2456 self.enforce_unique_update_post_images(&query.table, compiled_plan, &applied_chunk)?;
2457 for chunk in applied_chunk.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2458 if compiled_plan.row_touches_unique_columns
2459 || compiled_plan
2460 .row_contract_plan
2461 .as_ref()
2462 .is_some_and(|plan| plan.has_expressions)
2463 {
2464 let db = self.db();
2465 let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2466 for item in chunk {
2467 let fields =
2468 crate::application::ports::entity_row_fields_snapshot(&item.entity);
2469 let previous_id =
2470 item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2471 contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2472 }
2473 }
2474 self.persist_update_chunk(chunk)?;
2475 published += chunk.len();
2476 }
2477 Ok(())
2478 })();
2479 let published_mutations = &applied_chunk[..published];
2482 let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2483 if !query.suppress_events {
2484 self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2485 }
2486 persistence?;
2487
2488 if affected > 0 {
2489 self.note_table_write(&query.table);
2490 }
2491
2492 Ok((
2493 RuntimeQueryResult::dml_result(
2494 raw_query.to_string(),
2495 affected,
2496 "update",
2497 "runtime-dml",
2498 ),
2499 touched_ids,
2500 ))
2501 }
2502
2503 fn enforce_unique_update_post_images(
2504 &self,
2505 collection: &str,
2506 compiled_plan: &CompiledUpdatePlan,
2507 applied: &[AppliedEntityMutation],
2508 ) -> RedDBResult<()> {
2509 if !compiled_plan.touches_unique_index {
2510 return Ok(());
2511 }
2512 let post_images: Vec<_> = applied
2513 .iter()
2514 .map(|item| {
2515 (
2516 item.replaced_entity.as_ref().map_or(item.id, |old| old.id),
2517 crate::application::ports::entity_row_fields_snapshot(&item.entity),
2518 )
2519 })
2520 .collect();
2521 self.enforce_unique_updates(collection, &post_images)
2522 }
2523
2524 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2525 let mut static_field_assignments = Vec::new();
2526 let mut static_metadata_assignments = Vec::new();
2527 let mut dynamic_assignments = Vec::new();
2528 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2529 let mut row_modified_columns = Vec::new();
2530
2531 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2532 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2533 let metadata_key = resolve_sql_ttl_metadata_key(column);
2534 if compound_op.is_some() && metadata_key.is_some() {
2535 return Err(RedDBError::Query(format!(
2536 "compound assignment is only supported for row fields: {column}"
2537 )));
2538 }
2539 if compound_op.is_none() {
2540 if let Ok(value) = fold_expr_to_value(expr.clone()) {
2541 let unknown_text_target = metadata_key.is_none()
2544 && matches!(value, Value::Text(_))
2545 && row_contract_plan
2546 .as_ref()
2547 .and_then(|plan| plan.declared_rules.get(column))
2548 .is_none();
2549 if !unknown_text_target {
2550 if let Some(metadata_key) = metadata_key {
2551 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2552 let (canonical_key, canonical_value) =
2553 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2554 static_metadata_assignments
2555 .push((canonical_key.to_string(), canonical_value));
2556 } else {
2557 let is_secret_column = row_contract_plan
2558 .as_ref()
2559 .and_then(|plan| plan.declared_rules.get(column))
2560 .is_some_and(|rule| {
2561 rule.data_type == reddb_types::DataType::Secret
2562 });
2563 let value =
2564 self.resolve_secret_column_value(value, is_secret_column)?;
2565 static_field_assignments.push((
2566 column.clone(),
2567 normalize_row_update_assignment_with_plan(
2568 &query.table,
2569 column,
2570 value,
2571 row_contract_plan.as_ref(),
2572 )?,
2573 ));
2574 row_modified_columns.push(column.clone());
2575 }
2576 continue;
2577 }
2578 }
2579 }
2580
2581 dynamic_assignments.push(CompiledUpdateAssignment {
2582 column: column.clone(),
2583 expr: expr.clone(),
2584 compound_op,
2585 metadata_key,
2586 row_rule: if metadata_key.is_none() {
2587 if let Some(plan) = row_contract_plan.as_ref() {
2588 if plan.timestamps_enabled
2589 && (column == "created_at" || column == "updated_at")
2590 {
2591 return Err(RedDBError::Query(format!(
2592 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2593 query.table, column
2594 )));
2595 }
2596 if let Some(rule) = plan.declared_rules.get(column) {
2597 Some(rule.clone())
2598 } else if plan.strict_schema {
2599 return Err(RedDBError::Query(format!(
2600 "collection '{}' is strict and does not allow undeclared fields: {}",
2601 query.table, column
2602 )));
2603 } else {
2604 None
2605 }
2606 } else {
2607 None
2608 }
2609 } else {
2610 None
2611 },
2612 });
2613 if metadata_key.is_none() {
2614 row_modified_columns.push(column.clone());
2615 }
2616 }
2617
2618 let row_modified_columns = dedupe_update_columns(row_modified_columns);
2619 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2620 row_modified_columns.iter().any(|column| {
2621 plan.unique_columns
2622 .keys()
2623 .any(|unique| unique.eq_ignore_ascii_case(column))
2624 })
2625 });
2626
2627 let touches_unique_index = self
2628 .index_store_ref()
2629 .touches_unique_index(&query.table, &row_modified_columns);
2630
2631 if let Some(ttl_ms) = query.ttl_ms {
2632 static_metadata_assignments
2633 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2634 }
2635 if let Some(expires_at_ms) = query.expires_at_ms {
2636 static_metadata_assignments.push((
2637 "_expires_at".to_string(),
2638 metadata_u64_to_value(expires_at_ms),
2639 ));
2640 }
2641 for (key, val) in &query.with_metadata {
2642 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2643 }
2644
2645 Ok(CompiledUpdatePlan {
2646 static_field_assignments,
2647 static_metadata_assignments,
2648 dynamic_assignments,
2649 row_contract_plan,
2650 row_modified_columns,
2651 row_touches_unique_columns,
2652 touches_unique_index,
2653 })
2654 }
2655
2656 fn materialize_update_assignments_for_entity(
2657 &self,
2658 query: &UpdateQuery,
2659 entity: &UnifiedEntity,
2660 compiled_plan: &CompiledUpdatePlan,
2661 ) -> RedDBResult<MaterializedUpdateAssignments> {
2662 let mut assignments = MaterializedUpdateAssignments::default();
2663 let mut record: Option<UnifiedRecord> = None;
2664
2665 for assignment in &compiled_plan.dynamic_assignments {
2666 if assignment.compound_op.is_some()
2667 && !matches!(
2668 entity.data,
2669 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2670 )
2671 {
2672 return Err(RedDBError::Query(format!(
2673 "compound assignment is only supported for row or graph UPDATE column '{}'",
2674 assignment.column
2675 )));
2676 }
2677 if record.is_none() {
2678 record = runtime_any_record_from_entity_ref(entity);
2679 }
2680 let Some(record) = record.as_ref() else {
2681 return Err(RedDBError::Query(format!(
2682 "UPDATE could not materialize runtime record for entity {} in '{}'",
2683 entity.id.raw(),
2684 query.table
2685 )));
2686 };
2687 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2688 Some(self.inner.db.as_ref()),
2689 &assignment.expr,
2690 record,
2691 Some(query.table.as_str()),
2692 Some(query.table.as_str()),
2693 )
2694 .ok_or_else(|| {
2695 RedDBError::Query(format!(
2696 "failed to evaluate UPDATE expression for column '{}'",
2697 assignment.column
2698 ))
2699 })?;
2700 let value = if let Some(op) = assignment.compound_op {
2701 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2702 } else {
2703 rhs
2704 };
2705
2706 let sensitive = super::join_filter::expression_uses_secret(
2707 &assignment.expr,
2708 record,
2709 Some(&query.table),
2710 Some(&query.table),
2711 );
2712 let secret_target = assignment
2713 .row_rule
2714 .as_ref()
2715 .is_some_and(|rule| rule.data_type == reddb_types::DataType::Secret)
2716 || record
2717 .get(&assignment.column)
2718 .is_some_and(|value| matches!(value, Value::Secret(_)));
2719 if sensitive && !secret_target {
2720 return Err(RedDBError::Query(
2721 "secret-derived UPDATE values require a SECRET target column".into(),
2722 ));
2723 }
2724 if let Some(metadata_key) = assignment.metadata_key {
2725 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2726 let (canonical_key, canonical_value) =
2727 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2728 assignments
2729 .dynamic_metadata_assignments
2730 .push((canonical_key.to_string(), canonical_value));
2731 } else {
2732 assignments.dynamic_field_assignments.push((
2733 assignment.column.clone(),
2734 normalize_row_update_value_for_rule(
2735 &query.table,
2736 self.resolve_secret_column_value(
2737 value,
2738 assignment.row_rule.as_ref().is_some_and(|rule| {
2739 rule.data_type == reddb_types::DataType::Secret
2740 }) || record
2741 .get(&assignment.column)
2742 .is_some_and(|value| matches!(value, Value::Secret(_))),
2743 )?,
2744 assignment.row_rule.as_ref(),
2745 )?,
2746 ));
2747 }
2748 }
2749
2750 Ok(assignments)
2751 }
2752
2753 fn apply_materialized_update_for_entity(
2754 &self,
2755 query: &UpdateQuery,
2756 entity: UnifiedEntity,
2757 compiled_plan: &CompiledUpdatePlan,
2758 mut assignments: MaterializedUpdateAssignments,
2759 ) -> RedDBResult<AppliedEntityMutation> {
2760 if matches!(query.target, UpdateTarget::Kv)
2761 && !compiled_plan
2762 .static_field_assignments
2763 .iter()
2764 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2765 && !assignments
2766 .dynamic_field_assignments
2767 .iter()
2768 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2769 {
2770 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2771 .and_then(|record| record.get("key").cloned())
2772 {
2773 assignments
2774 .dynamic_field_assignments
2775 .push(("key".to_string(), key));
2776 }
2777 }
2778 if matches!(entity.data, EntityData::Row(_)) {
2779 return self.apply_loaded_sql_update_row_core(
2780 query.table.clone(),
2781 entity,
2782 &compiled_plan.static_field_assignments,
2783 assignments.dynamic_field_assignments,
2784 &compiled_plan.static_metadata_assignments,
2785 assignments.dynamic_metadata_assignments,
2786 compiled_plan.row_contract_plan.as_ref(),
2787 &compiled_plan.row_modified_columns,
2788 compiled_plan.row_touches_unique_columns,
2789 );
2790 }
2791
2792 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2793
2794 let operations = build_patch_operations_from_materialized_assignments(
2795 &entity,
2796 compiled_plan,
2797 assignments,
2798 );
2799 self.apply_loaded_patch_entity_core(
2800 query.table.clone(),
2801 entity,
2802 crate::json::Value::Null,
2803 operations,
2804 )
2805 }
2806
2807 pub fn execute_delete(
2809 &self,
2810 raw_query: &str,
2811 query: &DeleteQuery,
2812 ) -> RedDBResult<RuntimeQueryResult> {
2813 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2814 let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2815 self,
2816 &query.table,
2817 );
2818 let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2819 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2822 return Err(RedDBError::InvalidOperation(format!(
2823 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2824 query.table
2825 )));
2826 }
2827 crate::runtime::collection_contract::CollectionContractGate::check(
2831 self,
2832 &query.table,
2833 crate::runtime::collection_contract::MutationKind::Delete,
2834 )?;
2835
2836 if let Some(items) = query.returning.clone() {
2843 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2844 RedDBError::Query(
2845 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2846 )
2847 })?;
2848 let captured = self.execute_query(&select_sql)?;
2849
2850 let mut inner_query = query.clone();
2851 inner_query.returning = None;
2852 let _ = self.execute_delete(raw_query, &inner_query)?;
2853
2854 let snapshots: Vec<Vec<(String, Value)>> = captured
2855 .result
2856 .records
2857 .iter()
2858 .map(|rec| {
2859 rec.iter_fields()
2860 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2861 .collect()
2862 })
2863 .collect();
2864 let affected = snapshots.len() as u64;
2865 let result = build_returning_result(&items, &snapshots, None);
2866
2867 let mut response = RuntimeQueryResult::dml_result(
2868 raw_query.to_string(),
2869 affected,
2870 "delete",
2871 "runtime-dml-returning",
2872 );
2873 response.result = result;
2874 return Ok(response);
2875 }
2876 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2883 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2884 self,
2885 &query.table,
2886 reddb_rql::ast::PolicyAction::Delete,
2887 );
2888 let Some(policy) = rls_filter else {
2889 return Ok(RuntimeQueryResult::dml_result(
2890 raw_query.to_string(),
2891 0,
2892 "delete",
2893 "runtime-dml-rls",
2894 ));
2895 };
2896 let mut augmented = query.clone();
2901 augmented.filter = Some(match augmented.filter.take() {
2902 Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2903 None => policy,
2904 });
2905 return self.execute_delete_inner(raw_query, &augmented);
2906 }
2907 self.execute_delete_inner(raw_query, query)
2908 }
2909
2910 fn execute_delete_inner(
2911 &self,
2912 raw_query: &str,
2913 query: &DeleteQuery,
2914 ) -> RedDBResult<RuntimeQueryResult> {
2915 let topology_lock = self
2916 .index_store_ref()
2917 .collection_topology_lock(&query.table);
2918 let effective_filter = effective_delete_filter(query);
2919
2920 let scan = super::dml_target_scan::DmlTargetScan::new(
2924 self,
2925 &query.table,
2926 effective_filter.as_ref(),
2927 None,
2928 );
2929 let ids_to_delete = {
2930 let _topology_guard = topology_lock.read();
2931 scan.find_target_ids()?
2932 };
2933
2934 let needs_delete_events =
2937 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2938 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2939 scan.row_json_pre_images(&ids_to_delete)
2940 } else {
2941 HashMap::new()
2942 };
2943
2944 let mut affected: u64 = 0;
2945 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2946 let (count, lsns) =
2947 self.delete_entities_batch(&query.table, chunk, topology_lock.read())?;
2948 #[cfg(test)]
2949 self.index_store_ref().mutation_test_hook(
2950 &query.table,
2951 super::index_store::MutationTestPhase::BeforeEvents,
2952 );
2953 affected += count;
2954 if needs_delete_events && !lsns.is_empty() {
2955 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2959 self.emit_delete_events_for_collection(
2960 &query.table,
2961 deleted_chunk,
2962 &lsns,
2963 &pre_images,
2964 )?;
2965 }
2966 }
2967 pre_images.clear();
2968
2969 if affected > 0 {
2970 self.note_table_write(&query.table);
2971 }
2972
2973 Ok(RuntimeQueryResult::dml_result(
2974 raw_query.to_string(),
2975 affected,
2976 "delete",
2977 "runtime-dml",
2978 ))
2979 }
2980}
2981
2982#[cfg(test)]
2983mod tests {
2984 use crate::storage::wal::{WalReader, WalRecord};
2985 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2986 use crate::{RedDBOptions, RedDBRuntime};
2987 use reddb_types::Value;
2988 use std::path::Path;
2989
2990 #[test]
2991 fn concurrent_constant_updates_leave_one_visible_row_per_logical_id() {
2992 use std::sync::{Arc, Barrier};
2993
2994 const WRITERS: usize = 3;
2995 const ROUNDS: usize = 8;
2996 let rt = Arc::new(RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap());
2997 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
2998 .unwrap();
2999 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3000 .unwrap();
3001 let barrier = Arc::new(Barrier::new(WRITERS));
3002 let handles: Vec<_> = (0..WRITERS)
3003 .map(|writer| {
3004 let rt = Arc::clone(&rt);
3005 let barrier = Arc::clone(&barrier);
3006 std::thread::spawn(move || {
3007 for round in 0..ROUNDS {
3008 barrier.wait();
3009 rt.execute_query(&format!(
3010 "UPDATE capabilities SET observed_at = '{writer}-{round}' WHERE id = 'A'"
3011 ))
3012 .unwrap();
3013 }
3014 })
3015 })
3016 .collect();
3017 for handle in handles {
3018 handle.join().unwrap();
3019 }
3020 let selected = rt
3021 .execute_query("SELECT id, observed_at FROM capabilities WHERE id = 'A'")
3022 .unwrap();
3023 assert_eq!(
3024 selected.result.records.len(),
3025 1,
3026 "concurrent updates published multiple visible versions"
3027 );
3028 }
3029
3030 #[test]
3031 fn locked_constant_updates_preserve_unique_constraints() {
3032 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3033 rt.execute_query("CREATE TABLE accounts (id INT PRIMARY KEY, email TEXT UNIQUE)")
3034 .unwrap();
3035 rt.execute_query("INSERT INTO accounts (id, email) VALUES (1, 'a'), (2, 'b')")
3036 .unwrap();
3037 assert!(rt
3038 .execute_query("UPDATE accounts SET email = 'b' WHERE id = 1")
3039 .is_err());
3040 let selected = rt
3041 .execute_query("SELECT id FROM accounts WHERE email = 'a'")
3042 .unwrap();
3043 assert_eq!(selected.result.records.len(), 1);
3044 }
3045
3046 #[test]
3047 fn concurrent_transactions_do_not_both_commit_the_same_row_update() {
3048 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3049
3050 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3051 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3052 .unwrap();
3053 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3054 .unwrap();
3055 for connection in [41, 42] {
3056 set_current_connection_id(connection);
3057 rt.execute_query("BEGIN").unwrap();
3058 }
3059 set_current_connection_id(41);
3060 rt.execute_query("UPDATE capabilities SET observed_at = 'first' WHERE id = 'A'")
3061 .unwrap();
3062 set_current_connection_id(42);
3063 let competing =
3064 rt.execute_query("UPDATE capabilities SET observed_at = 'second' WHERE id = 'A'");
3065 set_current_connection_id(41);
3066 rt.execute_query("COMMIT").unwrap();
3067 set_current_connection_id(42);
3068 if competing.is_ok() {
3069 assert!(
3070 rt.execute_query("COMMIT").is_err(),
3071 "both writers committed"
3072 );
3073 } else {
3074 rt.execute_query("ROLLBACK").unwrap();
3075 }
3076 clear_current_connection_id();
3077 let selected = rt
3078 .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3079 .unwrap();
3080 assert_eq!(selected.result.records.len(), 1);
3081 }
3082
3083 #[test]
3084 fn transaction_and_autocommit_update_do_not_publish_two_live_versions() {
3085 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3086
3087 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3088 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3089 .unwrap();
3090 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3091 .unwrap();
3092 set_current_connection_id(41);
3093 rt.execute_query("BEGIN").unwrap();
3094 rt.execute_query("UPDATE capabilities SET observed_at = 'transaction' WHERE id = 'A'")
3095 .unwrap();
3096 clear_current_connection_id();
3097 let autocommit =
3098 rt.execute_query("UPDATE capabilities SET observed_at = 'auto' WHERE id = 'A'");
3099 set_current_connection_id(41);
3100 let commit = rt.execute_query("COMMIT");
3101 clear_current_connection_id();
3102 assert!(
3103 autocommit.is_err() || commit.is_err(),
3104 "conflicting writers both committed"
3105 );
3106 let selected = rt
3107 .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3108 .unwrap();
3109 assert_eq!(selected.result.records.len(), 1);
3110 }
3111
3112 #[test]
3113 fn transaction_update_does_not_target_rows_created_after_snapshot() {
3114 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3115
3116 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3117 rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3118 .unwrap();
3119 set_current_connection_id(41);
3120 rt.execute_query("BEGIN").unwrap();
3121 rt.execute_query("SELECT id FROM capabilities").unwrap();
3122 clear_current_connection_id();
3123 rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'after')")
3124 .unwrap();
3125 set_current_connection_id(41);
3126 let updated = rt
3127 .execute_query("UPDATE capabilities SET observed_at = 'changed' WHERE id = 'A'")
3128 .unwrap();
3129 assert_eq!(
3130 updated.affected_rows, 0,
3131 "updated a row outside the transaction snapshot"
3132 );
3133 rt.execute_query("COMMIT").unwrap();
3134 clear_current_connection_id();
3135 let selected = rt
3136 .execute_query("SELECT observed_at FROM capabilities WHERE id = 'A'")
3137 .unwrap();
3138 assert_eq!(selected.result.records.len(), 1);
3139 assert_eq!(
3140 selected.result.records[0].get("observed_at"),
3141 Some(&Value::text("after"))
3142 );
3143 }
3144
3145 #[test]
3146 fn autocommit_update_does_not_filter_on_uncommitted_row_image() {
3147 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3148
3149 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3150 rt.execute_query("CREATE TABLE capabilities (id TEXT, status TEXT, payload TEXT)")
3151 .unwrap();
3152 rt.execute_query(
3153 "INSERT INTO capabilities (id, status, payload) VALUES ('A', 'ready', 'old')",
3154 )
3155 .unwrap();
3156 set_current_connection_id(41);
3157 rt.execute_query("BEGIN").unwrap();
3158 rt.execute_query("UPDATE capabilities SET status = 'busy' WHERE id = 'A'")
3159 .unwrap();
3160 clear_current_connection_id();
3161 let autocommit =
3162 rt.execute_query("UPDATE capabilities SET payload = 'new' WHERE status = 'ready'");
3163 if let Ok(result) = &autocommit {
3164 assert_eq!(
3165 result.affected_rows, 1,
3166 "uncommitted status hid the committed target"
3167 );
3168 }
3169 set_current_connection_id(41);
3170 rt.execute_query("ROLLBACK").unwrap();
3171 clear_current_connection_id();
3172 let selected = rt
3173 .execute_query("SELECT status, payload FROM capabilities WHERE id = 'A'")
3174 .unwrap();
3175 assert_eq!(selected.result.records.len(), 1);
3176 assert_eq!(
3177 selected.result.records[0].get("status"),
3178 Some(&Value::text("ready"))
3179 );
3180 if autocommit.is_ok() {
3181 assert_eq!(
3182 selected.result.records[0].get("payload"),
3183 Some(&Value::text("new"))
3184 );
3185 }
3186 }
3187
3188 #[test]
3189 fn autocommit_update_skips_row_claimed_by_another_transaction() {
3190 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3191
3192 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3193 rt.execute_query("CREATE TABLE capabilities (id INT, rank INT, status TEXT, note TEXT)")
3194 .unwrap();
3195 rt.execute_query("CREATE INDEX capabilities_rank ON capabilities (rank)")
3196 .unwrap();
3197 rt.execute_query(
3198 "INSERT INTO capabilities (id, rank, status, note) VALUES (1, 10, 'ready', 'old')",
3199 )
3200 .unwrap();
3201 set_current_connection_id(41);
3202 rt.execute_query("BEGIN").unwrap();
3203 let claim = rt.execute_query("UPDATE capabilities SET status = 'claimed' WHERE status = 'ready' CLAIM LIMIT 1 ORDER BY rank ASC").unwrap();
3204 assert_eq!(claim.affected_rows, 1);
3205 set_current_connection_id(42);
3206 let other = rt
3207 .execute_query("UPDATE capabilities SET note = 'new' WHERE id = 1")
3208 .unwrap();
3209 assert_eq!(other.affected_rows, 0);
3210 set_current_connection_id(41);
3211 rt.execute_query("ROLLBACK").unwrap();
3212 clear_current_connection_id();
3213 let rows = rt
3214 .execute_query("SELECT status, note FROM capabilities WHERE id = 1")
3215 .unwrap();
3216 assert_eq!(rows.result.records.len(), 1);
3217 assert_eq!(
3218 rows.result.records[0].get("status"),
3219 Some(&Value::text("ready"))
3220 );
3221 assert_eq!(
3222 rows.result.records[0].get("note"),
3223 Some(&Value::text("old"))
3224 );
3225 }
3226
3227 fn persistent_operational_options(path: &Path) -> RedDBOptions {
3228 RedDBOptions::persistent(path)
3229 .with_storage_profile(StorageProfileSelection {
3230 deploy_profile: DeployProfile::Embedded,
3231 packaging: StoragePackaging::OperationalDirectory,
3232 replica_count: 0,
3233 managed_backup: false,
3234 wal_retention: false,
3235 })
3236 .unwrap()
3237 }
3238
3239 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
3240 WalReader::open(wal_path)
3241 .expect("wal opens")
3242 .iter()
3243 .map(|record| record.expect("wal record decodes").1)
3244 .filter_map(|record| match record {
3245 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
3246 _ => None,
3247 })
3248 .collect()
3249 }
3250
3251 fn action_contains_text(action: &[u8], needle: &str) -> bool {
3252 action
3253 .windows(needle.len())
3254 .any(|window| window == needle.as_bytes())
3255 }
3256
3257 fn claim_metric_count(
3258 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
3259 metric: &str,
3260 collection: &str,
3261 model: &str,
3262 ) -> u64 {
3263 let rows = match metric {
3264 "attempts" => &snapshot.attempts,
3265 "successful" => &snapshot.successful,
3266 "misses" => &snapshot.misses,
3267 "skipped_locked" => &snapshot.skipped_locked,
3268 other => panic!("unknown claim metric {other}"),
3269 };
3270 rows.iter()
3271 .find(|((actual_collection, actual_model), _)| {
3272 actual_collection == collection && actual_model == model
3273 })
3274 .map(|(_, count)| *count)
3275 .unwrap_or(0)
3276 }
3277
3278 fn assert_statement_writes_collections_in_one_new_wal_batch(
3279 rt: &RedDBRuntime,
3280 wal_path: &Path,
3281 statement: &str,
3282 source: &str,
3283 event_queue: &str,
3284 ) {
3285 let before_batches = store_commit_batches(wal_path).len();
3286
3287 rt.execute_query(statement).unwrap();
3288
3289 let batches = store_commit_batches(wal_path);
3290 let statement_batches = &batches[before_batches..];
3291 let source_batch = statement_batches
3292 .iter()
3293 .position(|actions| {
3294 actions.iter().any(|action| {
3295 action_contains_text(action, source)
3296 && !action_contains_text(action, event_queue)
3297 })
3298 })
3299 .expect("source collection write batch is present");
3300 let event_batch = statement_batches
3301 .iter()
3302 .position(|actions| {
3303 actions
3304 .iter()
3305 .any(|action| action_contains_text(action, event_queue))
3306 })
3307 .expect("event queue write batch is present");
3308
3309 assert_eq!(
3310 source_batch, event_batch,
3311 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
3312 );
3313 }
3314
3315 #[test]
3316 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
3317 let dir = tempfile::tempdir().unwrap();
3318 let db_path = dir.path().join("events_dual_write.rdb");
3319 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3320 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3321
3322 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3323 .unwrap();
3324 assert_statement_writes_collections_in_one_new_wal_batch(
3325 &rt,
3326 &wal_path,
3327 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
3328 "users",
3329 "users_events",
3330 );
3331 }
3332
3333 #[test]
3334 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
3335 let dir = tempfile::tempdir().unwrap();
3336 let db_path = dir.path().join("events_update_atomic.rdb");
3337 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3338 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3339
3340 rt.execute_query(
3341 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
3342 )
3343 .unwrap();
3344 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3345 .unwrap();
3346
3347 assert_statement_writes_collections_in_one_new_wal_batch(
3348 &rt,
3349 &wal_path,
3350 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
3351 "users",
3352 "user_updates",
3353 );
3354 }
3355
3356 #[test]
3357 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
3358 let dir = tempfile::tempdir().unwrap();
3359 let db_path = dir.path().join("events_delete_atomic.rdb");
3360 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3361 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3362
3363 rt.execute_query(
3364 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
3365 )
3366 .unwrap();
3367 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3368 .unwrap();
3369
3370 assert_statement_writes_collections_in_one_new_wal_batch(
3371 &rt,
3372 &wal_path,
3373 "DELETE FROM users WHERE id = 1",
3374 "users",
3375 "user_deletes",
3376 );
3377 }
3378
3379 #[test]
3380 fn update_where_id_in_with_hash_index_updates_expected_rows() {
3381 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3382 rt.execute_query("CREATE TABLE users (id INT, score INT)")
3383 .unwrap();
3384 for id in 0..5 {
3385 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
3386 .unwrap();
3387 }
3388 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
3389 .unwrap();
3390
3391 let updated = rt
3392 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
3393 .unwrap();
3394 assert_eq!(updated.affected_rows, 3);
3395
3396 let selected = rt
3397 .execute_query("SELECT id, score FROM users ORDER BY id")
3398 .unwrap();
3399 let scores: Vec<(i64, i64)> = selected
3400 .result
3401 .records
3402 .iter()
3403 .map(|record| {
3404 let id = match record.get("id").unwrap() {
3405 Value::Integer(value) => *value,
3406 other => panic!("expected integer id, got {other:?}"),
3407 };
3408 let score = match record.get("score").unwrap() {
3409 Value::Integer(value) => *value,
3410 other => panic!("expected integer score, got {other:?}"),
3411 };
3412 (id, score)
3413 })
3414 .collect();
3415 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3416 }
3417
3418 #[test]
3425 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3426 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3427 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3428 .unwrap();
3429 for id in 0..5 {
3430 rt.execute_query(&format!(
3431 "INSERT INTO items (id, score) VALUES ({id}, {})",
3432 id * 10
3433 ))
3434 .unwrap();
3435 }
3436 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3437 .unwrap();
3438
3439 let updated_one = rt
3443 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3444 .unwrap();
3445 assert_eq!(updated_one.affected_rows, 1);
3446
3447 let updated_many = rt
3451 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3452 .unwrap();
3453 assert_eq!(updated_many.affected_rows, 2);
3454
3455 let snapshot = rt
3456 .execute_query("SELECT id, score FROM items ORDER BY id")
3457 .unwrap();
3458 let pairs: Vec<(i64, i64)> = snapshot
3459 .result
3460 .records
3461 .iter()
3462 .map(|record| {
3463 let id = match record.get("id").unwrap() {
3464 Value::Integer(value) => *value,
3465 other => panic!("expected integer id, got {other:?}"),
3466 };
3467 let score = match record.get("score").unwrap() {
3468 Value::Integer(value) => *value,
3469 other => panic!("expected integer score, got {other:?}"),
3470 };
3471 (id, score)
3472 })
3473 .collect();
3474 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3475
3476 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3478 assert_eq!(updated_all.affected_rows, 5);
3479 let after = rt
3480 .execute_query("SELECT score FROM items ORDER BY id")
3481 .unwrap();
3482 let scores: Vec<i64> = after
3483 .result
3484 .records
3485 .iter()
3486 .map(|record| match record.get("score").unwrap() {
3487 Value::Integer(value) => *value,
3488 other => panic!("expected integer score, got {other:?}"),
3489 })
3490 .collect();
3491 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3492 }
3493
3494 #[test]
3500 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3501 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3502 rt.execute_query("CREATE TABLE items (id INT, score INT)")
3503 .unwrap();
3504 for id in 0..5 {
3505 rt.execute_query(&format!(
3506 "INSERT INTO items (id, score) VALUES ({id}, {})",
3507 id * 10
3508 ))
3509 .unwrap();
3510 }
3511 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3512 .unwrap();
3513
3514 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3517 assert_eq!(deleted_one.affected_rows, 1);
3518
3519 let deleted_many = rt
3523 .execute_query("DELETE FROM items WHERE score > 25")
3524 .unwrap();
3525 assert_eq!(deleted_many.affected_rows, 2);
3526
3527 let surviving = rt
3528 .execute_query("SELECT id FROM items ORDER BY id")
3529 .unwrap();
3530 let ids: Vec<i64> = surviving
3531 .result
3532 .records
3533 .iter()
3534 .map(|record| match record.get("id").unwrap() {
3535 Value::Integer(value) => *value,
3536 other => panic!("expected integer id, got {other:?}"),
3537 })
3538 .collect();
3539 assert_eq!(ids, vec![0, 1]);
3540
3541 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3543 assert_eq!(deleted_rest.affected_rows, 2);
3544 let empty = rt.execute_query("SELECT id FROM items").unwrap();
3545 assert!(empty.result.records.is_empty());
3546 }
3547
3548 #[test]
3553 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3554 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3555 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3556 .unwrap();
3557
3558 let inserted = rt
3561 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3562 .unwrap();
3563 assert_eq!(inserted.affected_rows, 1);
3564
3565 let update_err = rt
3567 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3568 .unwrap_err();
3569 let msg = format!("{update_err}");
3570 assert!(
3571 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3572 "expected UPDATE rejection message, got: {msg}"
3573 );
3574
3575 let delete_err = rt
3577 .execute_query("DELETE FROM events WHERE id = 1")
3578 .unwrap_err();
3579 let msg = format!("{delete_err}");
3580 assert!(
3581 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3582 "expected DELETE rejection message, got: {msg}"
3583 );
3584
3585 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3588 assert_eq!(surviving.result.records.len(), 1);
3589 }
3590
3591 #[test]
3595 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3596 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3597 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3598 .unwrap();
3599
3600 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3601 .unwrap();
3602 let updated = rt
3603 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3604 .unwrap();
3605 assert_eq!(updated.affected_rows, 1);
3606 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3607 assert_eq!(deleted.affected_rows, 1);
3608 }
3609
3610 #[test]
3611 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3612 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3613 rt.execute_query(
3614 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3615 )
3616 .unwrap();
3617
3618 let inserted = rt
3619 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3620 .unwrap();
3621 assert_eq!(inserted.affected_rows, 1);
3622
3623 let events = queue_payloads(&rt, "audit_log");
3624 assert_eq!(events.len(), 1);
3625 let event = events[0].as_object().expect("event payload object");
3626 assert!(event
3627 .get("event_id")
3628 .and_then(crate::json::Value::as_str)
3629 .is_some_and(|value| !value.is_empty()));
3630 assert_eq!(
3631 event.get("op").and_then(crate::json::Value::as_str),
3632 Some("insert")
3633 );
3634 assert_eq!(
3635 event.get("collection").and_then(crate::json::Value::as_str),
3636 Some("users")
3637 );
3638 assert_eq!(
3639 event.get("id").and_then(crate::json::Value::as_u64),
3640 Some(7)
3641 );
3642 assert!(event
3643 .get("ts")
3644 .and_then(crate::json::Value::as_u64)
3645 .is_some());
3646 assert!(event
3647 .get("lsn")
3648 .and_then(crate::json::Value::as_u64)
3649 .is_some());
3650 assert!(matches!(
3651 event.get("tenant"),
3652 Some(crate::json::Value::Null)
3653 ));
3654 assert!(matches!(
3655 event.get("before"),
3656 Some(crate::json::Value::Null)
3657 ));
3658 let after = event
3659 .get("after")
3660 .and_then(crate::json::Value::as_object)
3661 .expect("after object");
3662 assert_eq!(
3663 after.get("id").and_then(crate::json::Value::as_u64),
3664 Some(7)
3665 );
3666 assert_eq!(
3667 after.get("email").and_then(crate::json::Value::as_str),
3668 Some("a@example.com")
3669 );
3670 }
3671
3672 #[test]
3673 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3674 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3675 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3676 .unwrap();
3677
3678 rt.execute_query(
3679 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
3680 )
3681 .unwrap();
3682
3683 let events = queue_payloads(&rt, "users_events");
3684 assert_eq!(events.len(), 2);
3685 let mut previous_lsn = 0;
3686 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
3687 let object = event.as_object().expect("event payload object");
3688 assert_eq!(
3689 object.get("op").and_then(crate::json::Value::as_str),
3690 Some("insert")
3691 );
3692 assert_eq!(
3693 object.get("id").and_then(crate::json::Value::as_u64),
3694 Some(expected_id)
3695 );
3696 let lsn = object
3697 .get("lsn")
3698 .and_then(crate::json::Value::as_u64)
3699 .expect("event lsn");
3700 assert!(
3701 lsn > previous_lsn,
3702 "event LSNs should increase in row order"
3703 );
3704 previous_lsn = lsn;
3705 let after = object
3706 .get("after")
3707 .and_then(crate::json::Value::as_object)
3708 .expect("after object");
3709 assert_eq!(
3710 after.get("id").and_then(crate::json::Value::as_u64),
3711 Some(expected_id)
3712 );
3713 }
3714 }
3715
3716 #[test]
3717 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
3718 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3719 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
3720 .expect("create table");
3721 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
3723 .expect("create index");
3724 rt.execute_query(
3725 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
3726 (1, 10, 'ready'), (2, 20, 'ready')",
3727 )
3728 .expect("insert rows");
3729
3730 let claim_lock = rt
3731 .inner
3732 .rmw_locks
3733 .lock_for("claim_metric_locked", "__table_claim_update__");
3734 let _guard = claim_lock.lock();
3735 let updated = rt
3736 .execute_query(
3737 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
3738 CLAIM LIMIT 2 ORDER BY rank ASC",
3739 )
3740 .expect("claim skips locked candidates");
3741
3742 assert_eq!(updated.affected_rows, 0);
3743 let snapshot = rt.claim_telemetry_snapshot();
3744 assert_eq!(
3745 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
3746 1
3747 );
3748 assert_eq!(
3749 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
3750 2
3751 );
3752 assert_eq!(
3753 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
3754 0
3755 );
3756 assert_eq!(
3757 snapshot.skipped_locked.len(),
3758 1,
3759 "skipped-lock labels stay bounded to collection/model"
3760 );
3761 }
3762
3763 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3764 let result = rt
3765 .execute_query(&format!("QUEUE PEEK {queue} 10"))
3766 .expect("peek queue");
3767 result
3768 .result
3769 .records
3770 .iter()
3771 .map(
3772 |record| match record.get("payload").expect("payload column") {
3773 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3774 other => panic!("expected JSON queue payload, got {other:?}"),
3775 },
3776 )
3777 .collect()
3778 }
3779
3780 #[test]
3792 fn auto_index_id_fires_on_first_insert() {
3793 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3794 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
3795 .unwrap();
3796
3797 assert!(
3799 rt.index_store_ref()
3800 .find_index_for_column("bench_users", "id")
3801 .is_none(),
3802 "freshly created collection should not have an `id` index"
3803 );
3804
3805 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
3807 .unwrap();
3808
3809 let registered = rt
3811 .index_store_ref()
3812 .find_index_for_column("bench_users", "id")
3813 .expect("auto-index hook should have registered idx_id on first insert");
3814 assert_eq!(registered.name, "idx_id");
3815 assert_eq!(registered.collection, "bench_users");
3816 assert_eq!(registered.columns, vec!["id".to_string()]);
3817 assert!(matches!(
3818 registered.method,
3819 super::super::index_store::IndexMethodKind::Hash
3820 ));
3821
3822 for id in 2..=5 {
3825 rt.execute_query(&format!(
3826 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
3827 id * 10
3828 ))
3829 .unwrap();
3830 }
3831 for id in 1..=5 {
3832 let result = rt
3833 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
3834 .unwrap();
3835 assert_eq!(
3836 result.result.records.len(),
3837 1,
3838 "id={id} should match one row"
3839 );
3840 }
3841
3842 let deleted = rt
3847 .execute_query("DELETE FROM bench_users WHERE id = 3")
3848 .unwrap();
3849 assert_eq!(deleted.affected_rows, 1);
3850 }
3851
3852 #[test]
3857 fn auto_index_id_fires_on_first_bulk_insert() {
3858 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3859 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
3860 .unwrap();
3861
3862 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
3863 .unwrap();
3864
3865 let registered = rt
3866 .index_store_ref()
3867 .find_index_for_column("bench_bulk", "id")
3868 .expect("auto-index hook should fire on first bulk insert");
3869 assert_eq!(registered.name, "idx_id");
3870
3871 for id in 1..=3 {
3873 let result = rt
3874 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
3875 .unwrap();
3876 assert_eq!(result.result.records.len(), 1);
3877 }
3878 }
3879
3880 #[test]
3884 fn auto_index_id_skips_when_no_id_column() {
3885 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3886 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
3887 .unwrap();
3888 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
3889 .unwrap();
3890
3891 assert!(rt
3892 .index_store_ref()
3893 .find_index_for_column("plain", "id")
3894 .is_none());
3895 assert!(rt
3896 .index_store_ref()
3897 .find_index_for_column("plain", "uid")
3898 .is_none());
3899 }
3900
3901 #[test]
3906 fn auto_index_id_skips_when_index_already_exists() {
3907 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3908 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
3909 .unwrap();
3910 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
3912 .unwrap();
3913 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
3914 .unwrap();
3915
3916 let registered = rt
3917 .index_store_ref()
3918 .find_index_for_column("pre", "id")
3919 .expect("user index should still be there");
3920 assert_eq!(
3921 registered.name, "user_idx",
3922 "auto-index hook must not overwrite an existing index"
3923 );
3924 }
3925
3926 #[test]
3930 fn auto_index_id_dropped_with_collection() {
3931 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3932 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
3933 .unwrap();
3934 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
3935 .unwrap();
3936 assert!(rt
3937 .index_store_ref()
3938 .find_index_for_column("ephemeral", "id")
3939 .is_some());
3940
3941 rt.execute_query("DROP TABLE ephemeral").unwrap();
3942
3943 assert!(
3944 rt.index_store_ref()
3945 .find_index_for_column("ephemeral", "id")
3946 .is_none(),
3947 "implicit `idx_id` must be reaped when its collection drops"
3948 );
3949 }
3950
3951 #[test]
3956 fn auto_index_id_disabled_by_config() {
3957 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
3958 let rt = RedDBRuntime::with_options(opts).unwrap();
3959
3960 rt.execute_query("CREATE TABLE off (id INT, score INT)")
3961 .unwrap();
3962 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
3963 .unwrap();
3964
3965 assert!(
3966 rt.index_store_ref()
3967 .find_index_for_column("off", "id")
3968 .is_none(),
3969 "with auto_index_id=false, no implicit index should be created"
3970 );
3971 }
3972
3973 #[test]
3976 fn update_single_row_emits_update_event() {
3977 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3978 rt.execute_query(
3979 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
3980 )
3981 .unwrap();
3982 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3983 .unwrap();
3984
3985 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3986 .unwrap();
3987
3988 let events = queue_payloads(&rt, "audit_log");
3989 assert_eq!(events.len(), 1, "expected exactly 1 update event");
3990 let event = events[0].as_object().expect("event payload object");
3991 assert_eq!(
3992 event.get("op").and_then(crate::json::Value::as_str),
3993 Some("update")
3994 );
3995 assert_eq!(
3996 event.get("collection").and_then(crate::json::Value::as_str),
3997 Some("users")
3998 );
3999 assert!(event
4000 .get("event_id")
4001 .and_then(crate::json::Value::as_str)
4002 .is_some_and(|v| !v.is_empty()));
4003 let before = event
4004 .get("before")
4005 .and_then(crate::json::Value::as_object)
4006 .expect("before must be an object");
4007 let after = event
4008 .get("after")
4009 .and_then(crate::json::Value::as_object)
4010 .expect("after must be an object");
4011 assert_eq!(
4012 before.get("name").and_then(crate::json::Value::as_str),
4013 Some("Alice"),
4014 "before.name should be the old value"
4015 );
4016 assert_eq!(
4017 after.get("name").and_then(crate::json::Value::as_str),
4018 Some("Bob"),
4019 "after.name should be the new value"
4020 );
4021 }
4022
4023 #[test]
4024 fn update_event_only_includes_changed_fields() {
4025 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4026 rt.execute_query(
4027 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
4028 )
4029 .unwrap();
4030 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
4031 .unwrap();
4032
4033 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
4034 .unwrap();
4035
4036 let events = queue_payloads(&rt, "evts");
4037 assert_eq!(events.len(), 1);
4038 let event = events[0].as_object().unwrap();
4039 let before = event
4040 .get("before")
4041 .and_then(crate::json::Value::as_object)
4042 .unwrap();
4043 let after = event
4044 .get("after")
4045 .and_then(crate::json::Value::as_object)
4046 .unwrap();
4047 assert!(
4049 before.contains_key("name"),
4050 "before must include changed field"
4051 );
4052 assert!(
4053 after.contains_key("name"),
4054 "after must include changed field"
4055 );
4056 assert!(
4058 !before.contains_key("email"),
4059 "before must not include unchanged email"
4060 );
4061 assert!(
4062 !after.contains_key("email"),
4063 "after must not include unchanged email"
4064 );
4065 }
4066
4067 #[test]
4068 fn multi_row_update_emits_one_event_per_row() {
4069 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4070 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
4071 .unwrap();
4072 rt.execute_query(
4073 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
4074 )
4075 .unwrap();
4076
4077 rt.execute_query("UPDATE items SET status = 'done'")
4078 .unwrap();
4079
4080 let events = queue_payloads(&rt, "evts");
4081 assert_eq!(events.len(), 3, "expected one update event per row");
4082 for event in &events {
4083 let obj = event.as_object().unwrap();
4084 assert_eq!(
4085 obj.get("op").and_then(crate::json::Value::as_str),
4086 Some("update")
4087 );
4088 }
4089 }
4090
4091 #[test]
4092 fn delete_single_row_emits_delete_event() {
4093 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4094 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
4095 .unwrap();
4096 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
4097 .unwrap();
4098
4099 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
4100
4101 let events = queue_payloads(&rt, "del_log");
4102 assert_eq!(events.len(), 1);
4103 let event = events[0].as_object().expect("event payload object");
4104 assert_eq!(
4105 event.get("op").and_then(crate::json::Value::as_str),
4106 Some("delete")
4107 );
4108 assert_eq!(
4109 event.get("collection").and_then(crate::json::Value::as_str),
4110 Some("users")
4111 );
4112 assert!(event
4113 .get("event_id")
4114 .and_then(crate::json::Value::as_str)
4115 .is_some_and(|v| !v.is_empty()));
4116 let before = event
4117 .get("before")
4118 .and_then(crate::json::Value::as_object)
4119 .expect("before must be an object for delete");
4120 assert_eq!(
4121 before.get("id").and_then(crate::json::Value::as_u64),
4122 Some(42)
4123 );
4124 assert_eq!(
4125 before.get("name").and_then(crate::json::Value::as_str),
4126 Some("Alice")
4127 );
4128 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
4129 }
4130
4131 #[test]
4132 fn multi_row_delete_emits_one_event_per_row() {
4133 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4134 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
4135 .unwrap();
4136 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
4137 .unwrap();
4138
4139 rt.execute_query("DELETE FROM items").unwrap();
4140
4141 let events = queue_payloads(&rt, "del_log");
4142 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
4143 for event in &events {
4144 let obj = event.as_object().unwrap();
4145 assert_eq!(
4146 obj.get("op").and_then(crate::json::Value::as_str),
4147 Some("delete")
4148 );
4149 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
4150 }
4151 }
4152
4153 #[test]
4154 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
4155 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4156 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
4157 .unwrap();
4158
4159 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4160 .unwrap();
4161 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
4162
4163 let events = queue_payloads(&rt, "evts");
4164 assert!(
4165 events.is_empty(),
4166 "UPDATE-only filter must not emit INSERT or DELETE events"
4167 );
4168 }
4169
4170 #[test]
4173 fn suppress_events_on_insert_emits_no_events() {
4174 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4175 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4176 .unwrap();
4177
4178 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4179 .unwrap();
4180
4181 let events = queue_payloads(&rt, "evts");
4182 assert!(
4183 events.is_empty(),
4184 "SUPPRESS EVENTS must prevent INSERT events"
4185 );
4186 }
4187
4188 #[test]
4189 fn suppress_events_on_update_emits_no_events() {
4190 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4191 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4192 .unwrap();
4193 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4194 .unwrap();
4195 let _ = queue_payloads(&rt, "evts");
4197 rt.execute_query("QUEUE PURGE evts").unwrap();
4199
4200 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
4201 .unwrap();
4202
4203 let events = queue_payloads(&rt, "evts");
4204 assert!(
4205 events.is_empty(),
4206 "SUPPRESS EVENTS must prevent UPDATE events"
4207 );
4208 }
4209
4210 #[test]
4211 fn suppress_events_on_delete_emits_no_events() {
4212 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4213 rt.execute_query(
4214 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
4215 )
4216 .unwrap();
4217 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4218 .unwrap();
4219
4220 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
4221 .unwrap();
4222
4223 let events = queue_payloads(&rt, "evts");
4224 assert!(
4225 events.is_empty(),
4226 "SUPPRESS EVENTS must prevent DELETE events"
4227 );
4228 }
4229
4230 #[test]
4231 fn normal_insert_after_suppress_still_emits() {
4232 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4233 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4234 .unwrap();
4235
4236 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4237 .unwrap();
4238 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
4239 .unwrap();
4240
4241 let events = queue_payloads(&rt, "evts");
4242 assert_eq!(
4243 events.len(),
4244 1,
4245 "only the non-suppressed INSERT should emit"
4246 );
4247 assert_eq!(
4248 events[0].get("id").and_then(crate::json::Value::as_u64),
4249 Some(2)
4250 );
4251 }
4252}