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::presentation::entity_json::storage_value_to_json;
22use crate::runtime::mvcc::current_connection_id;
23use crate::storage::query::ast::{BinOp, Expr, FieldRef, ReturningItem, UpdateTarget};
24use crate::storage::query::sql_lowering::{
25 effective_delete_filter, effective_insert_rows, effective_update_filter, fold_expr_to_value,
26};
27use crate::storage::query::unified::{
28 sys_key_collection, sys_key_created_at, sys_key_kind, sys_key_rid, sys_key_tenant,
29 sys_key_updated_at, UnifiedRecord, UnifiedResult,
30};
31use crate::storage::unified::MetadataValue;
32use crate::storage::Metadata;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use super::*;
37use super::impl_dml_support::*;
41use super::impl_dml_claim::*;
45use super::impl_dml_returning::*;
46use super::impl_dml_update_analysis::*;
47use super::impl_dml_update_target::*;
48pub(super) use super::impl_dml_ttl::{canonicalize_sql_ttl_metadata, resolve_sql_ttl_metadata_key};
54
55const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
56pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
57pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
58
59#[derive(Clone)]
60pub(super) struct CompiledUpdateAssignment {
61 column: String,
62 expr: Expr,
63 compound_op: Option<BinOp>,
64 metadata_key: Option<&'static str>,
65 row_rule: Option<RowUpdateColumnRule>,
66}
67
68pub(super) struct CompiledUpdatePlan {
69 pub(super) static_field_assignments: Vec<(String, Value)>,
70 pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
71 dynamic_assignments: Vec<CompiledUpdateAssignment>,
72 row_contract_plan: Option<RowUpdateContractPlan>,
73 row_modified_columns: Vec<String>,
74 row_touches_unique_columns: bool,
75}
76
77#[derive(Default)]
78pub(super) struct MaterializedUpdateAssignments {
79 pub(super) dynamic_field_assignments: Vec<(String, Value)>,
80 pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
81}
82
83impl RedDBRuntime {
84 fn infer_unmarked_document_insert(
103 &self,
104 query: &InsertQuery,
105 ) -> RedDBResult<Option<InsertQuery>> {
106 if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
107 return Ok(None);
108 }
109 match self
110 .db()
111 .collection_contract_arc(&query.table)
112 .map(|contract| contract.declared_model)
113 {
114 Some(crate::catalog::CollectionModel::Document) => {
115 let mut rewritten = query.clone();
116 rewritten.entity_type = InsertEntityType::Document;
117 rewritten.columns = vec!["body".to_string()];
118 Ok(Some(rewritten))
119 }
120 Some(other) => Err(RedDBError::InvalidOperation(format!(
121 "collection '{table}' is declared as '{model}'; the bare \
122 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
123 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
124 to insert into a {model} collection",
125 table = query.table,
126 model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
127 ))),
128 None => Err(RedDBError::InvalidOperation(format!(
129 "collection '{table}' does not exist and the INSERT carries no model marker; \
130 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
131 (idempotent), or run `CREATE DOCUMENT {table}` first",
132 table = query.table,
133 ))),
134 }
135 }
136
137 fn resolve_unmarked_update_target(
150 &self,
151 query: &UpdateQuery,
152 ) -> RedDBResult<Option<UpdateQuery>> {
153 if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
156 ensure_update_dotted_targets_allowed(query, false)?;
157 return Ok(None);
158 }
159 let declared_model = self
160 .db()
161 .collection_contract_arc(&query.table)
162 .map(|contract| contract.declared_model);
163 let is_document = matches!(
164 declared_model,
165 Some(crate::catalog::CollectionModel::Document)
166 );
167 ensure_update_dotted_targets_allowed(query, is_document)?;
168 let inferred = match declared_model {
169 Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
170 Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
171 _ => return Ok(None),
172 };
173 let mut rewritten = query.clone();
174 rewritten.target = inferred;
175 Ok(Some(rewritten))
176 }
177
178 pub fn execute_insert(
183 &self,
184 raw_query: &str,
185 query: &InsertQuery,
186 ) -> RedDBResult<RuntimeQueryResult> {
187 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
188 crate::runtime::collection_contract::CollectionContractGate::check(
194 self,
195 &query.table,
196 crate::runtime::collection_contract::MutationKind::Insert,
197 )?;
198 let inferred_owned;
207 let query = match self.infer_unmarked_document_insert(query)? {
208 Some(rewritten) => {
209 inferred_owned = rewritten;
210 &inferred_owned
211 }
212 None => query,
213 };
214 let augmented_owned;
223 let query = match self.maybe_inject_tenant_column(query)? {
224 Some(new_q) => {
225 augmented_owned = new_q;
226 &augmented_owned
227 }
228 None => query,
229 };
230 self.check_insert_column_policy(query)?;
231 if let Some(ref embed_config) = query.auto_embed {
232 let provider =
236 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
237 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
241 if matches!(provider, crate::ai::AiProvider::Local) {
242 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
243 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
251 if model_name.is_empty() {
252 return Err(RedDBError::Query(
253 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
254 the local provider does not have an implicit default model"
255 .to_string(),
256 ));
257 }
258 crate::runtime::ai::local_embedding::preflight_local_embedding(
259 &self.inner.db,
260 model_name,
261 )?;
262 }
263 }
264
265 let mut inserted_count: u64 = 0;
266 let effective_rows =
267 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
268
269 let store = self.inner.db.store();
271 let _ = store.get_or_create_collection(&query.table);
272 let declared_model = self
273 .db()
274 .collection_contract_arc(&query.table)
275 .map(|contract| contract.declared_model);
276
277 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
278 if query.returning.is_some() {
279 Some(Vec::with_capacity(effective_rows.len()))
280 } else {
281 None
282 };
283 let mut returning_result: Option<UnifiedResult> = None;
284
285 if matches!(query.entity_type, InsertEntityType::Row)
286 && !matches!(
287 declared_model,
288 Some(crate::catalog::CollectionModel::TimeSeries)
289 )
290 {
291 let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
302 let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
303 Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
304 } else {
305 None
306 };
307 let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
308
309 if chain_mode && self.is_chain_integrity_broken(&query.table) {
312 return Err(RedDBError::InvalidOperation(format!(
313 "ChainIntegrityBroken: collection '{}' is locked until \
314 POST /collections/{}/clear-integrity-flag is called by an admin",
315 query.table, query.table
316 )));
317 }
318
319 let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
323 if chain_mode {
324 let mut cache = self.inner.chain_tip_cache.lock();
325 if let Some(existing) = cache.get(&query.table) {
326 Some(existing.clone())
327 } else if let Some(scanned) =
328 crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
329 {
330 cache.insert(query.table.clone(), scanned.clone());
331 Some(scanned)
332 } else {
333 None
334 }
335 } else {
336 None
337 };
338
339 let mut rows = Vec::with_capacity(effective_rows.len());
340 for row_values in &effective_rows {
341 if row_values.len() != query.columns.len() {
342 return Err(RedDBError::Query(format!(
343 "INSERT column count ({}) does not match value count ({})",
344 query.columns.len(),
345 row_values.len()
346 )));
347 }
348 let (mut fields, mut metadata) =
349 split_insert_metadata(self, &query.columns, row_values)?;
350 if chain_mode {
351 use crate::runtime::blockchain_kind::{
352 chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
353 COL_TIMESTAMP, RESERVED_COLUMNS,
354 };
355 let supplied_height = fields
356 .iter()
357 .find(|(k, _)| k == COL_BLOCK_HEIGHT)
358 .map(|(_, v)| v.clone());
359 let supplied_prev = fields
360 .iter()
361 .find(|(k, _)| k == COL_PREV_HASH)
362 .map(|(_, v)| v.clone());
363 let supplied_ts = fields
364 .iter()
365 .find(|(k, _)| k == COL_TIMESTAMP)
366 .map(|(_, v)| v.clone());
367 let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
368 let user_supplied_any = supplied_height.is_some()
369 || supplied_prev.is_some()
370 || supplied_ts.is_some()
371 || supplied_hash;
372
373 fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
374 let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
375
376 let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
377 Some(t) => (t.hash, t.height + 1),
378 None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
379 };
380 let server_now = crate::runtime::blockchain_kind::now_ms();
381
382 let (use_prev, use_height, use_ts) = if user_supplied_any {
383 if supplied_hash {
386 return Err(chain_conflict_error(
387 tip_next_height.saturating_sub(1),
388 tip_prev_hash,
389 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
390 server_now,
391 "hash column is engine-computed and cannot be supplied",
392 ));
393 }
394 let caller_prev = match &supplied_prev {
395 Some(Value::Blob(b)) if b.len() == 32 => {
396 let mut a = [0u8; 32];
397 a.copy_from_slice(b);
398 a
399 }
400 Some(Value::Text(s)) if s.len() == 64 => {
401 let mut a = [0u8; 32];
405 let mut ok = true;
406 for (i, slot) in a.iter_mut().enumerate() {
407 let pair = &s.as_ref()[i * 2..i * 2 + 2];
408 match u8::from_str_radix(pair, 16) {
409 Ok(byte) => *slot = byte,
410 Err(_) => {
411 ok = false;
412 break;
413 }
414 }
415 }
416 if !ok {
417 return Err(chain_conflict_error(
418 tip_next_height.saturating_sub(1),
419 tip_prev_hash,
420 chain_tip_full
421 .as_ref()
422 .map(|t| t.timestamp_ms)
423 .unwrap_or(0),
424 server_now,
425 "prev_hash is not valid hex",
426 ));
427 }
428 a
429 }
430 _ => {
431 return Err(chain_conflict_error(
432 tip_next_height.saturating_sub(1),
433 tip_prev_hash,
434 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
435 server_now,
436 "prev_hash missing or not a 32-byte Blob",
437 ));
438 }
439 };
440 if caller_prev != tip_prev_hash {
441 return Err(chain_conflict_error(
442 tip_next_height.saturating_sub(1),
443 tip_prev_hash,
444 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
445 server_now,
446 "prev_hash does not match current tip",
447 ));
448 }
449 let caller_height = match &supplied_height {
450 Some(Value::UnsignedInteger(v)) => *v,
451 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
452 _ => {
453 return Err(chain_conflict_error(
454 tip_next_height.saturating_sub(1),
455 tip_prev_hash,
456 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
457 server_now,
458 "block_height missing or not an unsigned integer",
459 ));
460 }
461 };
462 if caller_height != tip_next_height {
463 return Err(chain_conflict_error(
464 tip_next_height.saturating_sub(1),
465 tip_prev_hash,
466 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
467 server_now,
468 "block_height does not match tip+1",
469 ));
470 }
471 let caller_ts = match &supplied_ts {
472 Some(Value::UnsignedInteger(v)) => *v,
473 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
474 _ => {
475 return Err(chain_conflict_error(
476 tip_next_height.saturating_sub(1),
477 tip_prev_hash,
478 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
479 server_now,
480 "timestamp missing or not an unsigned integer",
481 ));
482 }
483 };
484 let drift = (caller_ts as i128) - (server_now as i128);
485 if drift.abs() > 60_000 {
486 return Err(chain_conflict_error(
487 tip_next_height.saturating_sub(1),
488 tip_prev_hash,
489 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
490 server_now,
491 "timestamp outside ±60s of server_time",
492 ));
493 }
494 (caller_prev, caller_height, caller_ts)
495 } else {
496 (tip_prev_hash, tip_next_height, server_now)
497 };
498
499 let (reserved, new_hash) =
500 crate::runtime::blockchain_kind::make_block_reserved_fields(
501 use_prev, use_height, use_ts, &payload,
502 );
503 fields.extend(reserved);
504 chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
505 height: use_height,
506 hash: new_hash,
507 timestamp_ms: use_ts,
508 });
509 }
510 if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
522 let (pk_col, sig_col, residual) =
523 crate::runtime::signed_writes_kind::split_signature_fields(fields);
524 let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
525 let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
526 crate::runtime::signed_writes_kind::verify_row(
527 ®,
528 pk_col.as_ref().map(|c| c.bytes.as_slice()),
529 sig_col.as_ref().map(|c| c.bytes.as_slice()),
530 &payload,
531 )
532 .map_err(crate::runtime::signed_writes_kind::map_error)?;
533 fields = residual;
534 if let Some(col) = pk_col {
539 fields.push((
540 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
541 col.raw_value,
542 ));
543 }
544 if let Some(col) = sig_col {
545 fields.push((
546 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
547 col.raw_value,
548 ));
549 }
550 }
551 merge_with_clauses(
552 &mut metadata,
553 query.ttl_ms,
554 query.expires_at_ms,
555 &query.with_metadata,
556 );
557 if let Some(snaps) = returning_snapshots.as_mut() {
558 snaps.push(fields.clone());
559 }
560 rows.push(CreateRowInput {
561 collection: query.table.clone(),
562 fields,
563 metadata,
564 node_links: Vec::new(),
565 vector_links: Vec::new(),
566 });
567 }
568 let outputs = self.create_rows_batch(CreateRowsBatchInput {
569 collection: query.table.clone(),
570 rows,
571 suppress_events: query.suppress_events,
572 })?;
573 inserted_count = outputs.len() as u64;
574
575 if chain_mode {
579 if let Some(new_tip) = chain_tip_full.as_ref() {
580 self.inner
581 .chain_tip_cache
582 .lock()
583 .insert(query.table.clone(), new_tip.clone());
584 }
585 }
586
587 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
595 let time_col = &spec.time_column;
596 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
598 for row in &effective_rows {
599 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
600 if *n >= 0 {
601 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
602 }
603 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
604 let _ = self.inner.db.hypertables().route(&query.table, *n);
605 }
606 }
607 }
608 }
609
610 if let (Some(items), Some(snaps)) =
611 (query.returning.as_ref(), returning_snapshots.take())
612 {
613 let snaps = row_insert_returning_snapshots(&outputs, snaps);
614 returning_result = Some(build_returning_result(items, &snaps, Some(&outputs)));
615 }
616 } else {
617 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
624 Vec::with_capacity(effective_rows.len());
625 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
626 {
627 Vec::with_capacity(effective_rows.len())
628 } else {
629 Vec::new()
630 };
631 if matches!(
632 query.entity_type,
633 InsertEntityType::Node | InsertEntityType::Edge
634 ) {
635 enum PreparedGraphInsert {
636 Node {
637 fields: Vec<(String, Value)>,
638 input: CreateNodeInput,
639 },
640 Edge {
641 fields: Vec<(String, Value)>,
642 input: CreateEdgeInput,
643 },
644 }
645
646 let mut prepared = Vec::with_capacity(effective_rows.len());
647 for row_values in &effective_rows {
648 if row_values.len() != query.columns.len() {
649 return Err(RedDBError::Query(format!(
650 "INSERT column count ({}) does not match value count ({})",
651 query.columns.len(),
652 row_values.len()
653 )));
654 }
655
656 match query.entity_type {
657 InsertEntityType::Node => {
658 let (node_values, mut metadata) =
659 split_insert_metadata(self, &query.columns, row_values)?;
660 merge_with_clauses(
661 &mut metadata,
662 query.ttl_ms,
663 query.expires_at_ms,
664 &query.with_metadata,
665 );
666 ensure_non_tree_reserved_metadata_entries(&metadata)?;
667 apply_collection_default_ttl_metadata(
668 self,
669 &query.table,
670 &mut metadata,
671 );
672 let (columns, values) = pairwise_columns_values(&node_values);
673 let label = find_column_value_string(&columns, &values, "label")?;
674 let node_type =
675 find_column_value_opt_string(&columns, &values, "node_type");
676 let properties = extract_remaining_properties(
677 &columns,
678 &values,
679 &["label", "node_type"],
680 );
681 crate::reserved_fields::ensure_no_reserved_public_item_fields(
682 properties.iter().map(|(key, _)| key.as_str()),
683 &format!("node '{}'", query.table),
684 )?;
685 prepared.push(PreparedGraphInsert::Node {
686 fields: node_values,
687 input: CreateNodeInput {
688 collection: query.table.clone(),
689 label,
690 node_type,
691 properties,
692 metadata,
693 embeddings: Vec::new(),
694 table_links: Vec::new(),
695 node_links: Vec::new(),
696 },
697 });
698 }
699 InsertEntityType::Edge => {
700 let (edge_values, mut metadata) =
701 split_insert_metadata(self, &query.columns, row_values)?;
702 merge_with_clauses(
703 &mut metadata,
704 query.ttl_ms,
705 query.expires_at_ms,
706 &query.with_metadata,
707 );
708 ensure_non_tree_reserved_metadata_entries(&metadata)?;
709 apply_collection_default_ttl_metadata(
710 self,
711 &query.table,
712 &mut metadata,
713 );
714 let (columns, values) = pairwise_columns_values(&edge_values);
715 let label = find_column_value_string(&columns, &values, "label")?;
716 ensure_non_tree_structural_edge_label(&label)?;
717 let from_id = resolve_edge_endpoint_any(
718 self.inner.db.store().as_ref(),
719 &query.table,
720 &columns,
721 &values,
722 &["from_rid", "from"],
723 )?;
724 let to_id = resolve_edge_endpoint_any(
725 self.inner.db.store().as_ref(),
726 &query.table,
727 &columns,
728 &values,
729 &["to_rid", "to"],
730 )?;
731 let weight = find_column_value_f32_opt(&columns, &values, "weight");
732 let properties = extract_remaining_properties(
733 &columns,
734 &values,
735 &["label", "from_rid", "to_rid", "from", "to", "weight"],
736 );
737 crate::reserved_fields::ensure_no_reserved_public_item_fields(
738 properties.iter().map(|(key, _)| key.as_str()),
739 &format!("edge '{}'", query.table),
740 )?;
741 prepared.push(PreparedGraphInsert::Edge {
742 fields: edge_values,
743 input: CreateEdgeInput {
744 collection: query.table.clone(),
745 label,
746 from: EntityId::new(from_id),
747 to: EntityId::new(to_id),
748 weight,
749 properties,
750 metadata,
751 },
752 });
753 }
754 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
755 }
756 }
757
758 ensure_graph_insert_contract(self, &query.table)?;
759 let mut batch = self.inner.db.batch();
760 let mut graph_index_fields: Vec<Vec<(String, Value)>> =
761 Vec::with_capacity(prepared.len());
762 for item in prepared {
763 match item {
764 PreparedGraphInsert::Node { fields, input } => {
765 if query.returning.is_some() {
766 returning_field_snaps.push(fields.clone());
767 }
768 graph_index_fields.push(fields);
769 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
770 batch = batch.add_node_with_type(
771 input.collection,
772 input.label,
773 node_type,
774 input.properties.into_iter().collect(),
775 input.metadata.into_iter().collect(),
776 );
777 }
778 PreparedGraphInsert::Edge { fields, input } => {
779 if query.returning.is_some() {
780 returning_field_snaps.push(fields.clone());
781 }
782 graph_index_fields.push(fields);
783 batch = batch.add_edge(
784 input.collection,
785 input.label,
786 input.from,
787 input.to,
788 input.weight.unwrap_or(1.0),
789 input.properties.into_iter().collect(),
790 input.metadata.into_iter().collect(),
791 );
792 }
793 }
794 }
795 let batch_result = batch
796 .execute()
797 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
798 let (ids, entity_kind) = match query.entity_type {
799 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
800 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
801 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
802 };
803 for id in &ids {
804 self.stamp_xmin_if_in_txn(&query.table, *id);
805 }
806 if !graph_index_fields.is_empty() {
807 let index_rows: Vec<_> = ids
808 .iter()
809 .zip(graph_index_fields)
810 .map(|(id, fields)| (*id, fields))
811 .collect();
812 self.inner
813 .index_store
814 .index_entity_insert_batch(&query.table, &index_rows)
815 .map_err(RedDBError::Internal)?;
816 }
817 if query.returning.is_some() {
818 returning_field_snaps = graph_insert_returning_snapshots(
819 self.inner.db.store().as_ref(),
820 &query.table,
821 &ids,
822 );
823 }
824 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
825 let store = self.inner.db.store();
826 entity_outputs.extend(ids.iter().map(|id| {
827 crate::application::entity::CreateEntityOutput {
828 id: *id,
829 entity: store.get(&query.table, *id),
830 }
831 }));
832 inserted_count = ids.len() as u64;
833 } else {
834 for row_values in &effective_rows {
835 if row_values.len() != query.columns.len() {
836 return Err(RedDBError::Query(format!(
837 "INSERT column count ({}) does not match value count ({})",
838 query.columns.len(),
839 row_values.len()
840 )));
841 }
842
843 match query.entity_type {
844 InsertEntityType::Row => {
845 if query.returning.is_some() {
846 return Err(RedDBError::Query(
847 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
848 .to_string(),
849 ));
850 }
851 let (fields, mut metadata) =
852 split_insert_metadata(self, &query.columns, row_values)?;
853 merge_with_clauses(
854 &mut metadata,
855 query.ttl_ms,
856 query.expires_at_ms,
857 &query.with_metadata,
858 );
859 self.insert_timeseries_point(&query.table, fields, metadata)?;
860 }
861 InsertEntityType::Node | InsertEntityType::Edge => {
862 unreachable!("NODE and EDGE are handled by the prepared graph path")
863 }
864 InsertEntityType::Vector => {
865 let (vector_values, mut metadata) =
866 split_insert_metadata(self, &query.columns, row_values)?;
867 merge_with_clauses(
868 &mut metadata,
869 query.ttl_ms,
870 query.expires_at_ms,
871 &query.with_metadata,
872 );
873 let (columns, values) = pairwise_columns_values(&vector_values);
874 let dense = find_column_value_vec_f32_any(
875 &columns,
876 &values,
877 &["dense", "embedding"],
878 )?;
879 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
880 let content =
881 find_column_value_opt_string(&columns, &values, "content");
882 if query.returning.is_some() {
883 returning_field_snaps.push(vector_values.clone());
884 }
885 let input = CreateVectorInput {
886 collection: query.table.clone(),
887 dense,
888 content,
889 metadata,
890 link_row: None,
891 link_node: None,
892 };
893 entity_outputs.push(self.create_vector(input)?);
894 }
895 InsertEntityType::Document => {
896 let (document_values, mut metadata) =
897 split_insert_metadata(self, &query.columns, row_values)?;
898 merge_with_clauses(
899 &mut metadata,
900 query.ttl_ms,
901 query.expires_at_ms,
902 &query.with_metadata,
903 );
904 let (columns, values) = pairwise_columns_values(&document_values);
905 let body = find_document_body_json(&columns, &values)?;
906 let input = CreateDocumentInput {
907 collection: query.table.clone(),
908 body,
909 metadata,
910 node_links: Vec::new(),
911 vector_links: Vec::new(),
912 };
913 let output = self.create_document(input)?;
914 if query.returning.is_some() {
915 let fields = output
916 .entity
917 .as_ref()
918 .map(entity_row_fields_snapshot)
919 .filter(|fields| !fields.is_empty())
920 .unwrap_or(document_values);
921 returning_field_snaps.push(fields);
922 }
923 entity_outputs.push(output);
924 }
925 InsertEntityType::Kv => {
926 let (kv_values, mut metadata) =
927 split_insert_metadata(self, &query.columns, row_values)?;
928 merge_with_clauses(
929 &mut metadata,
930 query.ttl_ms,
931 query.expires_at_ms,
932 &query.with_metadata,
933 );
934 let (columns, values) = pairwise_columns_values(&kv_values);
935 let key = find_column_value_string(&columns, &values, "key")?;
936 let value = find_column_value(&columns, &values, "value")?;
937 if query.returning.is_some() {
938 returning_field_snaps.push(kv_values.clone());
939 }
940 let input = CreateKvInput {
941 collection: query.table.clone(),
942 key,
943 value,
944 metadata,
945 };
946 entity_outputs.push(self.create_kv(input)?);
947 }
948 }
949
950 inserted_count += 1;
951 }
952 }
953
954 if let Some(items) = query.returning.as_ref() {
955 if !entity_outputs.is_empty() {
956 returning_result = Some(build_returning_result(
957 items,
958 &returning_field_snaps,
959 Some(&entity_outputs),
960 ));
961 }
962 }
963 }
964
965 if let Some(ref embed_config) = query.auto_embed {
967 let store = self.inner.db.store();
968 let provider =
969 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
970 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
971 let api_key = if is_local_provider {
976 String::new()
977 } else {
978 crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
979 };
980 let model = crate::ai::resolve_embeddings_model_from_runtime(
983 self,
984 &provider,
985 embed_config.model.as_deref(),
986 );
987
988 let manager = store
990 .get_collection(&query.table)
991 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
992 let entities = manager.query_all(|_| true);
993 let recent: Vec<_> = entities
994 .into_iter()
995 .rev()
996 .take(effective_rows.len())
997 .collect();
998
999 let entity_combos: Vec<(usize, String)> = recent
1001 .iter()
1002 .enumerate()
1003 .filter_map(|(i, entity)| {
1004 if let EntityData::Row(ref row) = entity.data {
1005 if let Some(ref named) = row.named {
1006 let texts: Vec<String> = embed_config
1007 .fields
1008 .iter()
1009 .filter_map(|field| match named.get(field) {
1010 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1011 _ => None,
1012 })
1013 .collect();
1014 if !texts.is_empty() {
1015 return Some((i, texts.join(" ")));
1016 }
1017 }
1018 }
1019 None
1020 })
1021 .collect();
1022
1023 if !entity_combos.is_empty() {
1024 let batch_texts: Vec<String> =
1026 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1027
1028 let embeddings = if is_local_provider {
1038 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1039 &self.inner.db,
1040 &model,
1041 batch_texts,
1042 )?;
1043 response.embeddings
1044 } else {
1045 let batch_client =
1046 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1047
1048 match tokio::runtime::Handle::try_current() {
1049 Ok(handle) => tokio::task::block_in_place(|| {
1050 handle.block_on(batch_client.embed_batch(
1051 &provider,
1052 &model,
1053 &api_key,
1054 batch_texts,
1055 ))
1056 }),
1057 Err(_) => {
1058 return Err(RedDBError::Query(
1059 "AUTO EMBED requires a Tokio runtime context".to_string(),
1060 ));
1061 }
1062 }
1063 .map_err(|e| RedDBError::Query(e.to_string()))?
1064 };
1065
1066 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1068 if dense.is_empty() {
1069 continue;
1070 }
1071 self.create_vector(CreateVectorInput {
1072 collection: query.table.clone(),
1073 dense,
1074 content: Some(combined.clone()),
1075 metadata: Vec::new(),
1076 link_row: None,
1077 link_node: None,
1078 })?;
1079 }
1080 }
1081 }
1082
1083 if inserted_count > 0 {
1084 self.note_table_write(&query.table);
1085 }
1086
1087 let mut result = RuntimeQueryResult::dml_result(
1088 raw_query.to_string(),
1089 inserted_count,
1090 "insert",
1091 "runtime-dml",
1092 );
1093 if let Some(returning) = returning_result {
1094 result.result = returning;
1095 }
1096 Ok(result)
1097 }
1098
1099 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1100 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1101 return Ok(());
1102 };
1103 if !auth_store.iam_authorization_enabled() {
1104 return Ok(());
1105 }
1106 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1107 return Ok(());
1108 };
1109
1110 let tenant = crate::runtime::impl_core::current_tenant();
1111 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1112 let request = crate::auth::ColumnAccessRequest {
1113 action: "insert".to_string(),
1114 schema: None,
1115 table: query.table.clone(),
1116 columns: query.columns.clone(),
1117 };
1118 let ctx = crate::auth::policies::EvalContext {
1119 principal_tenant: tenant.clone(),
1120 current_tenant: tenant,
1121 peer_ip: None,
1122 mfa_present: false,
1123 now_ms: crate::auth::now_ms(),
1124 principal_is_admin_role: role == crate::auth::Role::Admin,
1125 principal_is_platform_scoped: principal.tenant.is_none(),
1126 };
1127
1128 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1129 let table_allowed = matches!(
1130 outcome.table_decision,
1131 crate::auth::policies::Decision::Allow { .. }
1132 | crate::auth::policies::Decision::AdminBypass
1133 );
1134 if !table_allowed {
1135 return Err(RedDBError::Query(format!(
1136 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1137 outcome.table_resource.kind, outcome.table_resource.name
1138 )));
1139 }
1140 if let Some(denied) = outcome.first_denied_column() {
1141 return Err(RedDBError::Query(format!(
1142 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1143 denied.resource.kind, denied.resource.name
1144 )));
1145 }
1146
1147 Ok(())
1148 }
1149
1150 pub(crate) fn insert_timeseries_point(
1151 &self,
1152 collection: &str,
1153 fields: Vec<(String, Value)>,
1154 mut metadata: Vec<(String, MetadataValue)>,
1155 ) -> RedDBResult<EntityId> {
1156 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1157
1158 let (columns, values) = pairwise_columns_values(&fields);
1159 validate_timeseries_insert_columns(&columns)?;
1160
1161 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1170 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1171 if let Some(event_name) = event_name_opt.as_deref() {
1172 let store_for_schema = self.inner.db.store();
1173 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1174 .is_some()
1175 {
1176 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1177 super::analytics_schema_registry::validate(
1178 store_for_schema.as_ref(),
1179 event_name,
1180 payload_json,
1181 )
1182 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1183 }
1184 }
1185
1186 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1191 Some(m) => m,
1192 None => event_name_opt.clone().ok_or_else(|| {
1193 RedDBError::Query(
1194 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1195 )
1196 })?,
1197 };
1198 let value = match find_column_value_opt_string(&columns, &values, "value") {
1202 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1203 None => columns
1204 .iter()
1205 .position(|c| c.eq_ignore_ascii_case("value"))
1206 .and_then(|i| match &values[i] {
1207 Value::Float(f) => Some(*f),
1208 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1209 Value::UnsignedInteger(n) => Some(*n as f64),
1210 _ => None,
1211 })
1212 .unwrap_or(1.0),
1213 };
1214 let timestamp_ns =
1215 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1216 let mut tags = find_timeseries_tags(&columns, &values)?;
1217 if let Some(ref name) = event_name_opt {
1218 tags.entry("event_name".to_string())
1219 .or_insert_with(|| name.clone());
1220 }
1221 if let Some(ref payload) = payload_opt {
1222 tags.entry("payload".to_string())
1223 .or_insert_with(|| payload.clone());
1224 }
1225 let series_id = super::impl_timeseries::intern_timeseries_series(
1226 self.inner.db.store().as_ref(),
1227 collection,
1228 &metric,
1229 &tags,
1230 )?;
1231
1232 let mut entity = UnifiedEntity::new(
1233 EntityId::new(0),
1234 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1235 series: collection.to_string(),
1236 metric: metric.clone(),
1237 })),
1238 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1239 metric,
1240 series_id: Some(series_id),
1241 timestamp_ns,
1242 value,
1243 tags: HashMap::new(),
1244 }),
1245 );
1246 let writer_xid = match self.current_xid() {
1250 Some(xid) => xid,
1251 None => {
1252 let mgr = self.snapshot_manager();
1253 let xid = mgr.begin();
1254 mgr.commit(xid);
1255 xid
1256 }
1257 };
1258 entity.set_xmin(writer_xid);
1259
1260 let store = self.inner.db.store();
1261 let id = store
1262 .insert_auto(collection, entity)
1263 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1264
1265 if !metadata.is_empty() {
1266 let _ = store.set_metadata(
1267 collection,
1268 id,
1269 Metadata::with_fields(metadata.into_iter().collect()),
1270 );
1271 }
1272
1273 let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1274
1275 self.cdc_emit(
1276 crate::replication::cdc::ChangeOperation::Insert,
1277 collection,
1278 id.raw(),
1279 "timeseries",
1280 );
1281
1282 Ok(id)
1283 }
1284
1285 pub fn execute_update(
1290 &self,
1291 raw_query: &str,
1292 query: &UpdateQuery,
1293 ) -> RedDBResult<RuntimeQueryResult> {
1294 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1295 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1299 return Err(RedDBError::InvalidOperation(format!(
1300 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1301 query.table
1302 )));
1303 }
1304 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1311 return self.execute_queue_shaped_claim(raw_query, query);
1312 }
1313 let inferred_owned;
1321 let query = match self.resolve_unmarked_update_target(query)? {
1322 Some(rewritten) => {
1323 inferred_owned = rewritten;
1324 &inferred_owned
1325 }
1326 None => query,
1327 };
1328 crate::runtime::collection_contract::CollectionContractGate::check(
1334 self,
1335 &query.table,
1336 crate::runtime::collection_contract::MutationKind::Update,
1337 )?;
1338 ensure_update_target_contract(self, &query.table, query.target)?;
1339 ensure_kv_key_update_target_allowed(query)?;
1340 ensure_graph_identity_update_target_allowed(query)?;
1341
1342 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1348 let augmented_query: UpdateQuery;
1349 let effective_query: &UpdateQuery = if rls_gated {
1350 let update_filter = crate::runtime::impl_core::rls_policy_filter(
1351 self,
1352 &query.table,
1353 crate::storage::query::ast::PolicyAction::Update,
1354 );
1355 let Some(mut policy) = update_filter else {
1356 let mut response = RuntimeQueryResult::dml_result(
1359 raw_query.to_string(),
1360 0,
1361 "update",
1362 "runtime-dml-rls",
1363 );
1364 if let Some(items) = query.returning.clone() {
1365 response.result = build_returning_result(&items, &[], None);
1366 }
1367 return Ok(response);
1368 };
1369 if query.claim_limit.is_some() {
1370 let read_filter = crate::runtime::impl_core::rls_policy_filter(
1371 self,
1372 &query.table,
1373 crate::storage::query::ast::PolicyAction::Select,
1374 );
1375 let Some(read_policy) = read_filter else {
1376 let mut response = RuntimeQueryResult::dml_result(
1377 raw_query.to_string(),
1378 0,
1379 "update",
1380 "runtime-dml-rls",
1381 );
1382 if let Some(items) = query.returning.clone() {
1383 response.result = build_returning_result(&items, &[], None);
1384 }
1385 return Ok(response);
1386 };
1387 policy = crate::storage::query::ast::Filter::And(
1388 Box::new(read_policy),
1389 Box::new(policy),
1390 );
1391 }
1392 let mut augmented = query.clone();
1393 augmented.filter = Some(match augmented.filter.take() {
1394 Some(existing) => {
1395 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
1396 }
1397 None => policy,
1398 });
1399 augmented_query = augmented;
1400 &augmented_query
1401 } else {
1402 query
1403 };
1404
1405 if let Some(items) = effective_query.returning.clone() {
1410 let mut inner_query = effective_query.clone();
1411 inner_query.returning = None;
1412 let (mut response, touched_ids) =
1413 self.execute_update_inner_tracked(raw_query, &inner_query)?;
1414
1415 let mut snapshots = if matches!(
1416 effective_query.target,
1417 UpdateTarget::Nodes | UpdateTarget::Edges
1418 ) {
1419 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
1420 } else {
1421 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
1422 .row_snapshots(&touched_ids)
1423 };
1424 if matches!(effective_query.target, UpdateTarget::Kv) {
1425 restore_kv_returning_keys(
1426 self,
1427 &effective_query.table,
1428 &touched_ids,
1429 &mut snapshots,
1430 );
1431 }
1432
1433 response.result = build_returning_result(&items, &snapshots, None);
1434 response.engine = "runtime-dml-returning";
1435 return Ok(response);
1436 }
1437
1438 self.execute_update_inner(raw_query, effective_query)
1439 }
1440
1441 fn execute_update_inner(
1443 &self,
1444 raw_query: &str,
1445 query: &UpdateQuery,
1446 ) -> RedDBResult<RuntimeQueryResult> {
1447 self.execute_update_inner_tracked(raw_query, query)
1448 .map(|(res, _)| res)
1449 }
1450
1451 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
1460 if query.claim_limit.is_none()
1461 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
1462 {
1463 return Ok(());
1464 }
1465 let available_indexes: Vec<Vec<String>> = self
1466 .index_store_ref()
1467 .list_indices(&query.table)
1468 .into_iter()
1469 .map(|index| index.columns)
1470 .collect();
1471 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
1472 .map_err(RedDBError::InvalidOperation)
1473 }
1474
1475 fn execute_update_inner_tracked(
1476 &self,
1477 raw_query: &str,
1478 query: &UpdateQuery,
1479 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1480 self.enforce_claim_order_by_index_gate(query)?;
1481 let store = self.inner.db.store();
1482 let effective_filter = effective_update_filter(query);
1483 let compiled_plan = self.compile_update_plan(query)?;
1484 let needs_rmw_lock = update_needs_rmw_lock(query);
1485 let claim_model = update_model_name(update_target_model(query.target));
1486 if query.claim_limit.is_some() {
1487 self.inner
1488 .claim_telemetry
1489 .record_attempt(&query.table, claim_model);
1490 }
1491 let claim_lock = query.claim_limit.map(|_| {
1492 self.inner
1493 .rmw_locks
1494 .lock_for(&query.table, "__table_claim_update__")
1495 });
1496 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
1497 let Some(guard) = lock.try_lock() else {
1498 let skipped_locked = query.claim_limit.unwrap_or(1);
1499 self.inner.claim_telemetry.record_skipped_locked(
1500 &query.table,
1501 claim_model,
1502 skipped_locked,
1503 );
1504 tracing::debug!(
1505 target: "reddb::claim",
1506 collection = %query.table,
1507 model = claim_model,
1508 skipped_locked,
1509 "concurrent claim skipped locked candidates"
1510 );
1511 return Ok((
1512 RuntimeQueryResult::dml_result(
1513 raw_query.to_string(),
1514 0,
1515 "update",
1516 "runtime-dml",
1517 ),
1518 Vec::new(),
1519 ));
1520 };
1521 Some(guard)
1522 } else {
1523 None
1524 };
1525 let table_rmw_lock = if needs_rmw_lock {
1526 Some(
1527 self.inner
1528 .rmw_locks
1529 .lock_for(&query.table, "__table_rmw_update__"),
1530 )
1531 } else {
1532 None
1533 };
1534 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
1535 let mut touched_ids: Vec<EntityId> = Vec::new();
1536 let claim_cap = query.claim_limit.map(|limit| limit as usize);
1537 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
1538 let manager = store
1539 .get_collection(&query.table)
1540 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1541 let scan_limit = if query.order_by.is_empty() {
1542 limit_cap
1543 } else {
1544 None
1545 };
1546 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
1547 self,
1548 &query.table,
1549 effective_filter.as_ref(),
1550 scan_limit,
1551 query.target,
1552 );
1553 if needs_rmw_lock {
1554 target_scan = target_scan.with_live_table_rows();
1555 }
1556 let ids_to_update = target_scan.find_target_ids()?;
1557 let order_limit = if query.claim_limit.is_some() {
1558 None
1559 } else {
1560 limit_cap
1561 };
1562 let ids_to_update = if query.order_by.is_empty() {
1563 ids_to_update
1564 } else {
1565 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
1566 };
1567 let mut ids_to_update = if query.claim_limit.is_some() {
1568 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
1569 } else {
1570 ids_to_update
1571 };
1572 if let Some(claim_cap) = claim_cap {
1573 ids_to_update.truncate(claim_cap);
1574 }
1575 if query.claim_exact
1576 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
1577 {
1578 self.inner
1579 .claim_telemetry
1580 .record_miss(&query.table, claim_model);
1581 return Ok((
1582 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
1583 Vec::new(),
1584 ));
1585 }
1586
1587 if needs_rmw_lock {
1588 let result = self.execute_update_inner_tracked_locked(
1589 raw_query,
1590 query,
1591 &compiled_plan,
1592 &ids_to_update,
1593 effective_filter.as_ref(),
1594 )?;
1595 if query.claim_limit.is_some() {
1596 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
1597 }
1598 record_claim_outcome(
1599 &self.inner.claim_telemetry,
1600 query.claim_limit,
1601 &query.table,
1602 claim_model,
1603 result.0.affected_rows,
1604 );
1605 return Ok(result);
1606 }
1607
1608 let mut affected: u64 = 0;
1609 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
1610 let mut applied_chunk = Vec::with_capacity(chunk.len());
1611 for entity in manager.get_many(chunk).into_iter().flatten() {
1612 let assignments =
1613 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
1614 let applied = self.apply_materialized_update_for_entity(
1615 query,
1616 entity,
1617 &compiled_plan,
1618 assignments,
1619 )?;
1620 touched_ids.push(applied.id);
1621 applied_chunk.push(applied);
1622 }
1623 self.persist_update_chunk(&applied_chunk)?;
1624 affected += applied_chunk.len() as u64;
1625 let lsns = self.flush_update_chunk(&applied_chunk)?;
1626 if !query.suppress_events {
1627 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
1628 }
1629 }
1630
1631 if affected > 0 {
1632 self.note_table_write(&query.table);
1633 }
1634 if query.claim_limit.is_some() {
1635 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
1636 }
1637 record_claim_outcome(
1638 &self.inner.claim_telemetry,
1639 query.claim_limit,
1640 &query.table,
1641 claim_model,
1642 affected,
1643 );
1644
1645 Ok((
1646 RuntimeQueryResult::dml_result(
1647 raw_query.to_string(),
1648 affected,
1649 "update",
1650 "runtime-dml",
1651 ),
1652 touched_ids,
1653 ))
1654 }
1655
1656 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
1657 let conn_id = current_connection_id();
1658 let locks = self.inner.pending_claim_locks.read();
1659 ids.into_iter()
1660 .filter(|id| {
1661 let Some(entity) = self.inner.db.store().get(table, *id) else {
1662 return false;
1663 };
1664 let key = (table.to_string(), entity.logical_id());
1665 locks
1666 .get(&key)
1667 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
1668 })
1669 .collect()
1670 }
1671
1672 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
1673 if self.current_xid().is_none() || ids.is_empty() {
1674 return;
1675 }
1676
1677 let conn_id = current_connection_id();
1678 let store = self.inner.db.store();
1679 let mut locks = self.inner.pending_claim_locks.write();
1680 for id in ids {
1681 let Some(entity) = store.get(table, *id) else {
1682 continue;
1683 };
1684 locks.insert((table.to_string(), entity.logical_id()), conn_id);
1685 }
1686 }
1687
1688 fn execute_update_inner_tracked_locked(
1689 &self,
1690 raw_query: &str,
1691 query: &UpdateQuery,
1692 compiled_plan: &CompiledUpdatePlan,
1693 ids_to_update: &[EntityId],
1694 effective_filter: Option<&Filter>,
1695 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1696 let store = self.inner.db.store();
1697 let mut touched_ids = Vec::new();
1698 let mut lock_entries = Vec::new();
1699
1700 for id in ids_to_update {
1701 let Some(candidate) = store.get(&query.table, *id) else {
1702 continue;
1703 };
1704 let logical_id = candidate.logical_id();
1705 let lock_key = format!("row:{}", logical_id.raw());
1706 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
1707 lock_entries.push((lock_key, logical_id, rmw_lock));
1708 }
1709
1710 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
1711 lock_entries.dedup_by(|left, right| left.0 == right.0);
1712 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
1713
1714 let mut applied_chunk = Vec::new();
1715 for (_, logical_id, _) in &lock_entries {
1716 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
1717 else {
1718 continue;
1719 };
1720 if let Some(filter) = effective_filter {
1721 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
1722 Some(self.inner.db.as_ref()),
1723 &entity,
1724 filter,
1725 &query.table,
1726 &query.table,
1727 ) {
1728 continue;
1729 }
1730 }
1731
1732 let assignments =
1733 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
1734 let applied = self.apply_materialized_update_for_entity(
1735 query,
1736 entity,
1737 compiled_plan,
1738 assignments,
1739 )?;
1740 touched_ids.push(applied.id);
1741 applied_chunk.push(applied);
1742 }
1743
1744 let affected = applied_chunk.len() as u64;
1745 if !applied_chunk.is_empty() {
1746 self.persist_update_chunk(&applied_chunk)?;
1747 let lsns = self.flush_update_chunk(&applied_chunk)?;
1748 if !query.suppress_events {
1749 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
1750 }
1751 }
1752
1753 if affected > 0 {
1754 self.note_table_write(&query.table);
1755 }
1756
1757 Ok((
1758 RuntimeQueryResult::dml_result(
1759 raw_query.to_string(),
1760 affected,
1761 "update",
1762 "runtime-dml",
1763 ),
1764 touched_ids,
1765 ))
1766 }
1767
1768 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
1769 let mut static_field_assignments = Vec::new();
1770 let mut static_metadata_assignments = Vec::new();
1771 let mut dynamic_assignments = Vec::new();
1772 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
1773 let mut row_modified_columns = Vec::new();
1774
1775 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
1776 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
1777 let metadata_key = resolve_sql_ttl_metadata_key(column);
1778 if compound_op.is_some() && metadata_key.is_some() {
1779 return Err(RedDBError::Query(format!(
1780 "compound assignment is only supported for row fields: {column}"
1781 )));
1782 }
1783 if compound_op.is_none() {
1784 if let Ok(value) = fold_expr_to_value(expr.clone()) {
1785 if let Some(metadata_key) = metadata_key {
1786 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
1787 let (canonical_key, canonical_value) =
1788 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
1789 static_metadata_assignments
1790 .push((canonical_key.to_string(), canonical_value));
1791 } else {
1792 let value = self.resolve_crypto_sentinel(value)?;
1793 static_field_assignments.push((
1794 column.clone(),
1795 normalize_row_update_assignment_with_plan(
1796 &query.table,
1797 column,
1798 value,
1799 row_contract_plan.as_ref(),
1800 )?,
1801 ));
1802 row_modified_columns.push(column.clone());
1803 }
1804 continue;
1805 }
1806 }
1807
1808 dynamic_assignments.push(CompiledUpdateAssignment {
1809 column: column.clone(),
1810 expr: expr.clone(),
1811 compound_op,
1812 metadata_key,
1813 row_rule: if metadata_key.is_none() {
1814 if let Some(plan) = row_contract_plan.as_ref() {
1815 if plan.timestamps_enabled
1816 && (column == "created_at" || column == "updated_at")
1817 {
1818 return Err(RedDBError::Query(format!(
1819 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1820 query.table, column
1821 )));
1822 }
1823 if let Some(rule) = plan.declared_rules.get(column) {
1824 Some(rule.clone())
1825 } else if plan.strict_schema {
1826 return Err(RedDBError::Query(format!(
1827 "collection '{}' is strict and does not allow undeclared fields: {}",
1828 query.table, column
1829 )));
1830 } else {
1831 None
1832 }
1833 } else {
1834 None
1835 }
1836 } else {
1837 None
1838 },
1839 });
1840 if metadata_key.is_none() {
1841 row_modified_columns.push(column.clone());
1842 }
1843 }
1844
1845 let row_modified_columns = dedupe_update_columns(row_modified_columns);
1846 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
1847 row_modified_columns.iter().any(|column| {
1848 plan.unique_columns
1849 .keys()
1850 .any(|unique| unique.eq_ignore_ascii_case(column))
1851 })
1852 });
1853
1854 if let Some(ttl_ms) = query.ttl_ms {
1855 static_metadata_assignments
1856 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
1857 }
1858 if let Some(expires_at_ms) = query.expires_at_ms {
1859 static_metadata_assignments.push((
1860 "_expires_at".to_string(),
1861 metadata_u64_to_value(expires_at_ms),
1862 ));
1863 }
1864 for (key, val) in &query.with_metadata {
1865 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
1866 }
1867
1868 Ok(CompiledUpdatePlan {
1869 static_field_assignments,
1870 static_metadata_assignments,
1871 dynamic_assignments,
1872 row_contract_plan,
1873 row_modified_columns,
1874 row_touches_unique_columns,
1875 })
1876 }
1877
1878 fn materialize_update_assignments_for_entity(
1879 &self,
1880 query: &UpdateQuery,
1881 entity: &UnifiedEntity,
1882 compiled_plan: &CompiledUpdatePlan,
1883 ) -> RedDBResult<MaterializedUpdateAssignments> {
1884 let mut assignments = MaterializedUpdateAssignments::default();
1885 let mut record: Option<UnifiedRecord> = None;
1886
1887 for assignment in &compiled_plan.dynamic_assignments {
1888 if assignment.compound_op.is_some()
1889 && !matches!(
1890 entity.data,
1891 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
1892 )
1893 {
1894 return Err(RedDBError::Query(format!(
1895 "compound assignment is only supported for row or graph UPDATE column '{}'",
1896 assignment.column
1897 )));
1898 }
1899 if record.is_none() {
1900 record = runtime_any_record_from_entity_ref(entity);
1901 }
1902 let Some(record) = record.as_ref() else {
1903 return Err(RedDBError::Query(format!(
1904 "UPDATE could not materialize runtime record for entity {} in '{}'",
1905 entity.id.raw(),
1906 query.table
1907 )));
1908 };
1909 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
1910 Some(self.inner.db.as_ref()),
1911 &assignment.expr,
1912 record,
1913 Some(query.table.as_str()),
1914 Some(query.table.as_str()),
1915 )
1916 .ok_or_else(|| {
1917 RedDBError::Query(format!(
1918 "failed to evaluate UPDATE expression for column '{}'",
1919 assignment.column
1920 ))
1921 })?;
1922 let value = if let Some(op) = assignment.compound_op {
1923 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
1924 } else {
1925 rhs
1926 };
1927
1928 if let Some(metadata_key) = assignment.metadata_key {
1929 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
1930 let (canonical_key, canonical_value) =
1931 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
1932 assignments
1933 .dynamic_metadata_assignments
1934 .push((canonical_key.to_string(), canonical_value));
1935 } else {
1936 assignments.dynamic_field_assignments.push((
1937 assignment.column.clone(),
1938 normalize_row_update_value_for_rule(
1939 &query.table,
1940 self.resolve_crypto_sentinel(value)?,
1941 assignment.row_rule.as_ref(),
1942 )?,
1943 ));
1944 }
1945 }
1946
1947 Ok(assignments)
1948 }
1949
1950 fn apply_materialized_update_for_entity(
1951 &self,
1952 query: &UpdateQuery,
1953 entity: UnifiedEntity,
1954 compiled_plan: &CompiledUpdatePlan,
1955 mut assignments: MaterializedUpdateAssignments,
1956 ) -> RedDBResult<AppliedEntityMutation> {
1957 if matches!(query.target, UpdateTarget::Kv)
1958 && !compiled_plan
1959 .static_field_assignments
1960 .iter()
1961 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
1962 && !assignments
1963 .dynamic_field_assignments
1964 .iter()
1965 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
1966 {
1967 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
1968 .and_then(|record| record.get("key").cloned())
1969 {
1970 assignments
1971 .dynamic_field_assignments
1972 .push(("key".to_string(), key));
1973 }
1974 }
1975 if matches!(entity.data, EntityData::Row(_)) {
1976 return self.apply_loaded_sql_update_row_core(
1977 query.table.clone(),
1978 entity,
1979 &compiled_plan.static_field_assignments,
1980 assignments.dynamic_field_assignments,
1981 &compiled_plan.static_metadata_assignments,
1982 assignments.dynamic_metadata_assignments,
1983 compiled_plan.row_contract_plan.as_ref(),
1984 &compiled_plan.row_modified_columns,
1985 compiled_plan.row_touches_unique_columns,
1986 );
1987 }
1988
1989 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
1990
1991 let operations = build_patch_operations_from_materialized_assignments(
1992 &entity,
1993 compiled_plan,
1994 assignments,
1995 );
1996 self.apply_loaded_patch_entity_core(
1997 query.table.clone(),
1998 entity,
1999 crate::json::Value::Null,
2000 operations,
2001 )
2002 }
2003
2004 pub fn execute_delete(
2006 &self,
2007 raw_query: &str,
2008 query: &DeleteQuery,
2009 ) -> RedDBResult<RuntimeQueryResult> {
2010 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2011 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2014 return Err(RedDBError::InvalidOperation(format!(
2015 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2016 query.table
2017 )));
2018 }
2019 crate::runtime::collection_contract::CollectionContractGate::check(
2023 self,
2024 &query.table,
2025 crate::runtime::collection_contract::MutationKind::Delete,
2026 )?;
2027
2028 if let Some(items) = query.returning.clone() {
2035 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2036 RedDBError::Query(
2037 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2038 )
2039 })?;
2040 let captured = self.execute_query(&select_sql)?;
2041
2042 let mut inner_query = query.clone();
2043 inner_query.returning = None;
2044 let _ = self.execute_delete(raw_query, &inner_query)?;
2045
2046 let snapshots: Vec<Vec<(String, Value)>> = captured
2047 .result
2048 .records
2049 .iter()
2050 .map(|rec| {
2051 rec.iter_fields()
2052 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2053 .collect()
2054 })
2055 .collect();
2056 let affected = snapshots.len() as u64;
2057 let result = build_returning_result(&items, &snapshots, None);
2058
2059 let mut response = RuntimeQueryResult::dml_result(
2060 raw_query.to_string(),
2061 affected,
2062 "delete",
2063 "runtime-dml-returning",
2064 );
2065 response.result = result;
2066 return Ok(response);
2067 }
2068 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2075 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2076 self,
2077 &query.table,
2078 crate::storage::query::ast::PolicyAction::Delete,
2079 );
2080 let Some(policy) = rls_filter else {
2081 return Ok(RuntimeQueryResult::dml_result(
2082 raw_query.to_string(),
2083 0,
2084 "delete",
2085 "runtime-dml-rls",
2086 ));
2087 };
2088 let mut augmented = query.clone();
2093 augmented.filter = Some(match augmented.filter.take() {
2094 Some(existing) => {
2095 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
2096 }
2097 None => policy,
2098 });
2099 return self.execute_delete_inner(raw_query, &augmented);
2100 }
2101 self.execute_delete_inner(raw_query, query)
2102 }
2103
2104 fn execute_delete_inner(
2105 &self,
2106 raw_query: &str,
2107 query: &DeleteQuery,
2108 ) -> RedDBResult<RuntimeQueryResult> {
2109 let effective_filter = effective_delete_filter(query);
2110
2111 let scan = super::dml_target_scan::DmlTargetScan::new(
2115 self,
2116 &query.table,
2117 effective_filter.as_ref(),
2118 None,
2119 );
2120 let ids_to_delete = scan.find_target_ids()?;
2121
2122 let needs_delete_events =
2125 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2126 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2127 scan.row_json_pre_images(&ids_to_delete)
2128 } else {
2129 HashMap::new()
2130 };
2131
2132 let mut affected: u64 = 0;
2133 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2134 let (count, lsns) = self.delete_entities_batch(&query.table, chunk)?;
2135 affected += count;
2136 if needs_delete_events && !lsns.is_empty() {
2137 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2141 self.emit_delete_events_for_collection(
2142 &query.table,
2143 deleted_chunk,
2144 &lsns,
2145 &pre_images,
2146 )?;
2147 }
2148 }
2149 pre_images.clear();
2150
2151 if affected > 0 {
2152 self.note_table_write(&query.table);
2153 }
2154
2155 Ok(RuntimeQueryResult::dml_result(
2156 raw_query.to_string(),
2157 affected,
2158 "delete",
2159 "runtime-dml",
2160 ))
2161 }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166 use crate::storage::schema::Value;
2167 use crate::storage::wal::{WalReader, WalRecord};
2168 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2169 use crate::{RedDBOptions, RedDBRuntime};
2170 use std::path::Path;
2171
2172 fn persistent_operational_options(path: &Path) -> RedDBOptions {
2173 RedDBOptions::persistent(path)
2174 .with_storage_profile(StorageProfileSelection {
2175 deploy_profile: DeployProfile::Embedded,
2176 packaging: StoragePackaging::OperationalDirectory,
2177 replica_count: 0,
2178 managed_backup: false,
2179 wal_retention: false,
2180 })
2181 .unwrap()
2182 }
2183
2184 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
2185 WalReader::open(wal_path)
2186 .expect("wal opens")
2187 .iter()
2188 .map(|record| record.expect("wal record decodes").1)
2189 .filter_map(|record| match record {
2190 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
2191 _ => None,
2192 })
2193 .collect()
2194 }
2195
2196 fn action_contains_text(action: &[u8], needle: &str) -> bool {
2197 action
2198 .windows(needle.len())
2199 .any(|window| window == needle.as_bytes())
2200 }
2201
2202 fn claim_metric_count(
2203 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
2204 metric: &str,
2205 collection: &str,
2206 model: &str,
2207 ) -> u64 {
2208 let rows = match metric {
2209 "attempts" => &snapshot.attempts,
2210 "successful" => &snapshot.successful,
2211 "misses" => &snapshot.misses,
2212 "skipped_locked" => &snapshot.skipped_locked,
2213 other => panic!("unknown claim metric {other}"),
2214 };
2215 rows.iter()
2216 .find(|((actual_collection, actual_model), _)| {
2217 actual_collection == collection && actual_model == model
2218 })
2219 .map(|(_, count)| *count)
2220 .unwrap_or(0)
2221 }
2222
2223 fn assert_statement_writes_collections_in_one_new_wal_batch(
2224 rt: &RedDBRuntime,
2225 wal_path: &Path,
2226 statement: &str,
2227 source: &str,
2228 event_queue: &str,
2229 ) {
2230 let before_batches = store_commit_batches(wal_path).len();
2231
2232 rt.execute_query(statement).unwrap();
2233
2234 let batches = store_commit_batches(wal_path);
2235 let statement_batches = &batches[before_batches..];
2236 let source_batch = statement_batches
2237 .iter()
2238 .position(|actions| {
2239 actions.iter().any(|action| {
2240 action_contains_text(action, source)
2241 && !action_contains_text(action, event_queue)
2242 })
2243 })
2244 .expect("source collection write batch is present");
2245 let event_batch = statement_batches
2246 .iter()
2247 .position(|actions| {
2248 actions
2249 .iter()
2250 .any(|action| action_contains_text(action, event_queue))
2251 })
2252 .expect("event queue write batch is present");
2253
2254 assert_eq!(
2255 source_batch, event_batch,
2256 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
2257 );
2258 }
2259
2260 #[test]
2261 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
2262 let dir = tempfile::tempdir().unwrap();
2263 let db_path = dir.path().join("events_dual_write.rdb");
2264 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2265 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2266
2267 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2268 .unwrap();
2269 assert_statement_writes_collections_in_one_new_wal_batch(
2270 &rt,
2271 &wal_path,
2272 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
2273 "users",
2274 "users_events",
2275 );
2276 }
2277
2278 #[test]
2279 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
2280 let dir = tempfile::tempdir().unwrap();
2281 let db_path = dir.path().join("events_update_atomic.rdb");
2282 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2283 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2284
2285 rt.execute_query(
2286 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
2287 )
2288 .unwrap();
2289 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2290 .unwrap();
2291
2292 assert_statement_writes_collections_in_one_new_wal_batch(
2293 &rt,
2294 &wal_path,
2295 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
2296 "users",
2297 "user_updates",
2298 );
2299 }
2300
2301 #[test]
2302 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
2303 let dir = tempfile::tempdir().unwrap();
2304 let db_path = dir.path().join("events_delete_atomic.rdb");
2305 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2306 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2307
2308 rt.execute_query(
2309 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
2310 )
2311 .unwrap();
2312 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2313 .unwrap();
2314
2315 assert_statement_writes_collections_in_one_new_wal_batch(
2316 &rt,
2317 &wal_path,
2318 "DELETE FROM users WHERE id = 1",
2319 "users",
2320 "user_deletes",
2321 );
2322 }
2323
2324 #[test]
2325 fn update_where_id_in_with_hash_index_updates_expected_rows() {
2326 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2327 rt.execute_query("CREATE TABLE users (id INT, score INT)")
2328 .unwrap();
2329 for id in 0..5 {
2330 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
2331 .unwrap();
2332 }
2333 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
2334 .unwrap();
2335
2336 let updated = rt
2337 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
2338 .unwrap();
2339 assert_eq!(updated.affected_rows, 3);
2340
2341 let selected = rt
2342 .execute_query("SELECT id, score FROM users ORDER BY id")
2343 .unwrap();
2344 let scores: Vec<(i64, i64)> = selected
2345 .result
2346 .records
2347 .iter()
2348 .map(|record| {
2349 let id = match record.get("id").unwrap() {
2350 Value::Integer(value) => *value,
2351 other => panic!("expected integer id, got {other:?}"),
2352 };
2353 let score = match record.get("score").unwrap() {
2354 Value::Integer(value) => *value,
2355 other => panic!("expected integer score, got {other:?}"),
2356 };
2357 (id, score)
2358 })
2359 .collect();
2360 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
2361 }
2362
2363 #[test]
2370 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
2371 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2372 rt.execute_query("CREATE TABLE items (id INT, score INT)")
2373 .unwrap();
2374 for id in 0..5 {
2375 rt.execute_query(&format!(
2376 "INSERT INTO items (id, score) VALUES ({id}, {})",
2377 id * 10
2378 ))
2379 .unwrap();
2380 }
2381 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
2382 .unwrap();
2383
2384 let updated_one = rt
2388 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
2389 .unwrap();
2390 assert_eq!(updated_one.affected_rows, 1);
2391
2392 let updated_many = rt
2396 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
2397 .unwrap();
2398 assert_eq!(updated_many.affected_rows, 2);
2399
2400 let snapshot = rt
2401 .execute_query("SELECT id, score FROM items ORDER BY id")
2402 .unwrap();
2403 let pairs: Vec<(i64, i64)> = snapshot
2404 .result
2405 .records
2406 .iter()
2407 .map(|record| {
2408 let id = match record.get("id").unwrap() {
2409 Value::Integer(value) => *value,
2410 other => panic!("expected integer id, got {other:?}"),
2411 };
2412 let score = match record.get("score").unwrap() {
2413 Value::Integer(value) => *value,
2414 other => panic!("expected integer score, got {other:?}"),
2415 };
2416 (id, score)
2417 })
2418 .collect();
2419 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
2420
2421 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
2423 assert_eq!(updated_all.affected_rows, 5);
2424 let after = rt
2425 .execute_query("SELECT score FROM items ORDER BY id")
2426 .unwrap();
2427 let scores: Vec<i64> = after
2428 .result
2429 .records
2430 .iter()
2431 .map(|record| match record.get("score").unwrap() {
2432 Value::Integer(value) => *value,
2433 other => panic!("expected integer score, got {other:?}"),
2434 })
2435 .collect();
2436 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
2437 }
2438
2439 #[test]
2445 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
2446 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2447 rt.execute_query("CREATE TABLE items (id INT, score INT)")
2448 .unwrap();
2449 for id in 0..5 {
2450 rt.execute_query(&format!(
2451 "INSERT INTO items (id, score) VALUES ({id}, {})",
2452 id * 10
2453 ))
2454 .unwrap();
2455 }
2456 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
2457 .unwrap();
2458
2459 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
2462 assert_eq!(deleted_one.affected_rows, 1);
2463
2464 let deleted_many = rt
2468 .execute_query("DELETE FROM items WHERE score > 25")
2469 .unwrap();
2470 assert_eq!(deleted_many.affected_rows, 2);
2471
2472 let surviving = rt
2473 .execute_query("SELECT id FROM items ORDER BY id")
2474 .unwrap();
2475 let ids: Vec<i64> = surviving
2476 .result
2477 .records
2478 .iter()
2479 .map(|record| match record.get("id").unwrap() {
2480 Value::Integer(value) => *value,
2481 other => panic!("expected integer id, got {other:?}"),
2482 })
2483 .collect();
2484 assert_eq!(ids, vec![0, 1]);
2485
2486 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
2488 assert_eq!(deleted_rest.affected_rows, 2);
2489 let empty = rt.execute_query("SELECT id FROM items").unwrap();
2490 assert!(empty.result.records.is_empty());
2491 }
2492
2493 #[test]
2498 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
2499 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2500 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
2501 .unwrap();
2502
2503 let inserted = rt
2506 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
2507 .unwrap();
2508 assert_eq!(inserted.affected_rows, 1);
2509
2510 let update_err = rt
2512 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
2513 .unwrap_err();
2514 let msg = format!("{update_err}");
2515 assert!(
2516 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
2517 "expected UPDATE rejection message, got: {msg}"
2518 );
2519
2520 let delete_err = rt
2522 .execute_query("DELETE FROM events WHERE id = 1")
2523 .unwrap_err();
2524 let msg = format!("{delete_err}");
2525 assert!(
2526 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
2527 "expected DELETE rejection message, got: {msg}"
2528 );
2529
2530 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
2533 assert_eq!(surviving.result.records.len(), 1);
2534 }
2535
2536 #[test]
2540 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
2541 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2542 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
2543 .unwrap();
2544
2545 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
2546 .unwrap();
2547 let updated = rt
2548 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
2549 .unwrap();
2550 assert_eq!(updated.affected_rows, 1);
2551 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
2552 assert_eq!(deleted.affected_rows, 1);
2553 }
2554
2555 #[test]
2556 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
2557 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2558 rt.execute_query(
2559 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
2560 )
2561 .unwrap();
2562
2563 let inserted = rt
2564 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
2565 .unwrap();
2566 assert_eq!(inserted.affected_rows, 1);
2567
2568 let events = queue_payloads(&rt, "audit_log");
2569 assert_eq!(events.len(), 1);
2570 let event = events[0].as_object().expect("event payload object");
2571 assert!(event
2572 .get("event_id")
2573 .and_then(crate::json::Value::as_str)
2574 .is_some_and(|value| !value.is_empty()));
2575 assert_eq!(
2576 event.get("op").and_then(crate::json::Value::as_str),
2577 Some("insert")
2578 );
2579 assert_eq!(
2580 event.get("collection").and_then(crate::json::Value::as_str),
2581 Some("users")
2582 );
2583 assert_eq!(
2584 event.get("id").and_then(crate::json::Value::as_u64),
2585 Some(7)
2586 );
2587 assert!(event
2588 .get("ts")
2589 .and_then(crate::json::Value::as_u64)
2590 .is_some());
2591 assert!(event
2592 .get("lsn")
2593 .and_then(crate::json::Value::as_u64)
2594 .is_some());
2595 assert!(matches!(
2596 event.get("tenant"),
2597 Some(crate::json::Value::Null)
2598 ));
2599 assert!(matches!(
2600 event.get("before"),
2601 Some(crate::json::Value::Null)
2602 ));
2603 let after = event
2604 .get("after")
2605 .and_then(crate::json::Value::as_object)
2606 .expect("after object");
2607 assert_eq!(
2608 after.get("id").and_then(crate::json::Value::as_u64),
2609 Some(7)
2610 );
2611 assert_eq!(
2612 after.get("email").and_then(crate::json::Value::as_str),
2613 Some("a@example.com")
2614 );
2615 }
2616
2617 #[test]
2618 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
2619 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2620 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2621 .unwrap();
2622
2623 rt.execute_query(
2624 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
2625 )
2626 .unwrap();
2627
2628 let events = queue_payloads(&rt, "users_events");
2629 assert_eq!(events.len(), 2);
2630 let mut previous_lsn = 0;
2631 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
2632 let object = event.as_object().expect("event payload object");
2633 assert_eq!(
2634 object.get("op").and_then(crate::json::Value::as_str),
2635 Some("insert")
2636 );
2637 assert_eq!(
2638 object.get("id").and_then(crate::json::Value::as_u64),
2639 Some(expected_id)
2640 );
2641 let lsn = object
2642 .get("lsn")
2643 .and_then(crate::json::Value::as_u64)
2644 .expect("event lsn");
2645 assert!(
2646 lsn > previous_lsn,
2647 "event LSNs should increase in row order"
2648 );
2649 previous_lsn = lsn;
2650 let after = object
2651 .get("after")
2652 .and_then(crate::json::Value::as_object)
2653 .expect("after object");
2654 assert_eq!(
2655 after.get("id").and_then(crate::json::Value::as_u64),
2656 Some(expected_id)
2657 );
2658 }
2659 }
2660
2661 #[test]
2662 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
2663 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
2664 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
2665 .expect("create table");
2666 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
2668 .expect("create index");
2669 rt.execute_query(
2670 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
2671 (1, 10, 'ready'), (2, 20, 'ready')",
2672 )
2673 .expect("insert rows");
2674
2675 let claim_lock = rt
2676 .inner
2677 .rmw_locks
2678 .lock_for("claim_metric_locked", "__table_claim_update__");
2679 let _guard = claim_lock.lock();
2680 let updated = rt
2681 .execute_query(
2682 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
2683 CLAIM LIMIT 2 ORDER BY rank ASC",
2684 )
2685 .expect("claim skips locked candidates");
2686
2687 assert_eq!(updated.affected_rows, 0);
2688 let snapshot = rt.claim_telemetry_snapshot();
2689 assert_eq!(
2690 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
2691 1
2692 );
2693 assert_eq!(
2694 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
2695 2
2696 );
2697 assert_eq!(
2698 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
2699 0
2700 );
2701 assert_eq!(
2702 snapshot.skipped_locked.len(),
2703 1,
2704 "skipped-lock labels stay bounded to collection/model"
2705 );
2706 }
2707
2708 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
2709 let result = rt
2710 .execute_query(&format!("QUEUE PEEK {queue} 10"))
2711 .expect("peek queue");
2712 result
2713 .result
2714 .records
2715 .iter()
2716 .map(
2717 |record| match record.get("payload").expect("payload column") {
2718 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
2719 other => panic!("expected JSON queue payload, got {other:?}"),
2720 },
2721 )
2722 .collect()
2723 }
2724
2725 #[test]
2737 fn auto_index_id_fires_on_first_insert() {
2738 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2739 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
2740 .unwrap();
2741
2742 assert!(
2744 rt.index_store_ref()
2745 .find_index_for_column("bench_users", "id")
2746 .is_none(),
2747 "freshly created collection should not have an `id` index"
2748 );
2749
2750 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
2752 .unwrap();
2753
2754 let registered = rt
2756 .index_store_ref()
2757 .find_index_for_column("bench_users", "id")
2758 .expect("auto-index hook should have registered idx_id on first insert");
2759 assert_eq!(registered.name, "idx_id");
2760 assert_eq!(registered.collection, "bench_users");
2761 assert_eq!(registered.columns, vec!["id".to_string()]);
2762 assert!(matches!(
2763 registered.method,
2764 super::super::index_store::IndexMethodKind::Hash
2765 ));
2766
2767 for id in 2..=5 {
2770 rt.execute_query(&format!(
2771 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
2772 id * 10
2773 ))
2774 .unwrap();
2775 }
2776 for id in 1..=5 {
2777 let result = rt
2778 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
2779 .unwrap();
2780 assert_eq!(
2781 result.result.records.len(),
2782 1,
2783 "id={id} should match one row"
2784 );
2785 }
2786
2787 let deleted = rt
2792 .execute_query("DELETE FROM bench_users WHERE id = 3")
2793 .unwrap();
2794 assert_eq!(deleted.affected_rows, 1);
2795 }
2796
2797 #[test]
2802 fn auto_index_id_fires_on_first_bulk_insert() {
2803 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2804 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
2805 .unwrap();
2806
2807 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
2808 .unwrap();
2809
2810 let registered = rt
2811 .index_store_ref()
2812 .find_index_for_column("bench_bulk", "id")
2813 .expect("auto-index hook should fire on first bulk insert");
2814 assert_eq!(registered.name, "idx_id");
2815
2816 for id in 1..=3 {
2818 let result = rt
2819 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
2820 .unwrap();
2821 assert_eq!(result.result.records.len(), 1);
2822 }
2823 }
2824
2825 #[test]
2829 fn auto_index_id_skips_when_no_id_column() {
2830 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2831 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
2832 .unwrap();
2833 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
2834 .unwrap();
2835
2836 assert!(rt
2837 .index_store_ref()
2838 .find_index_for_column("plain", "id")
2839 .is_none());
2840 assert!(rt
2841 .index_store_ref()
2842 .find_index_for_column("plain", "uid")
2843 .is_none());
2844 }
2845
2846 #[test]
2851 fn auto_index_id_skips_when_index_already_exists() {
2852 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2853 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
2854 .unwrap();
2855 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
2857 .unwrap();
2858 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
2859 .unwrap();
2860
2861 let registered = rt
2862 .index_store_ref()
2863 .find_index_for_column("pre", "id")
2864 .expect("user index should still be there");
2865 assert_eq!(
2866 registered.name, "user_idx",
2867 "auto-index hook must not overwrite an existing index"
2868 );
2869 }
2870
2871 #[test]
2875 fn auto_index_id_dropped_with_collection() {
2876 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2877 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
2878 .unwrap();
2879 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
2880 .unwrap();
2881 assert!(rt
2882 .index_store_ref()
2883 .find_index_for_column("ephemeral", "id")
2884 .is_some());
2885
2886 rt.execute_query("DROP TABLE ephemeral").unwrap();
2887
2888 assert!(
2889 rt.index_store_ref()
2890 .find_index_for_column("ephemeral", "id")
2891 .is_none(),
2892 "implicit `idx_id` must be reaped when its collection drops"
2893 );
2894 }
2895
2896 #[test]
2901 fn auto_index_id_disabled_by_config() {
2902 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
2903 let rt = RedDBRuntime::with_options(opts).unwrap();
2904
2905 rt.execute_query("CREATE TABLE off (id INT, score INT)")
2906 .unwrap();
2907 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
2908 .unwrap();
2909
2910 assert!(
2911 rt.index_store_ref()
2912 .find_index_for_column("off", "id")
2913 .is_none(),
2914 "with auto_index_id=false, no implicit index should be created"
2915 );
2916 }
2917
2918 #[test]
2921 fn update_single_row_emits_update_event() {
2922 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2923 rt.execute_query(
2924 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
2925 )
2926 .unwrap();
2927 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
2928 .unwrap();
2929
2930 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
2931 .unwrap();
2932
2933 let events = queue_payloads(&rt, "audit_log");
2934 assert_eq!(events.len(), 1, "expected exactly 1 update event");
2935 let event = events[0].as_object().expect("event payload object");
2936 assert_eq!(
2937 event.get("op").and_then(crate::json::Value::as_str),
2938 Some("update")
2939 );
2940 assert_eq!(
2941 event.get("collection").and_then(crate::json::Value::as_str),
2942 Some("users")
2943 );
2944 assert!(event
2945 .get("event_id")
2946 .and_then(crate::json::Value::as_str)
2947 .is_some_and(|v| !v.is_empty()));
2948 let before = event
2949 .get("before")
2950 .and_then(crate::json::Value::as_object)
2951 .expect("before must be an object");
2952 let after = event
2953 .get("after")
2954 .and_then(crate::json::Value::as_object)
2955 .expect("after must be an object");
2956 assert_eq!(
2957 before.get("name").and_then(crate::json::Value::as_str),
2958 Some("Alice"),
2959 "before.name should be the old value"
2960 );
2961 assert_eq!(
2962 after.get("name").and_then(crate::json::Value::as_str),
2963 Some("Bob"),
2964 "after.name should be the new value"
2965 );
2966 }
2967
2968 #[test]
2969 fn update_event_only_includes_changed_fields() {
2970 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2971 rt.execute_query(
2972 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
2973 )
2974 .unwrap();
2975 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
2976 .unwrap();
2977
2978 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
2979 .unwrap();
2980
2981 let events = queue_payloads(&rt, "evts");
2982 assert_eq!(events.len(), 1);
2983 let event = events[0].as_object().unwrap();
2984 let before = event
2985 .get("before")
2986 .and_then(crate::json::Value::as_object)
2987 .unwrap();
2988 let after = event
2989 .get("after")
2990 .and_then(crate::json::Value::as_object)
2991 .unwrap();
2992 assert!(
2994 before.contains_key("name"),
2995 "before must include changed field"
2996 );
2997 assert!(
2998 after.contains_key("name"),
2999 "after must include changed field"
3000 );
3001 assert!(
3003 !before.contains_key("email"),
3004 "before must not include unchanged email"
3005 );
3006 assert!(
3007 !after.contains_key("email"),
3008 "after must not include unchanged email"
3009 );
3010 }
3011
3012 #[test]
3013 fn multi_row_update_emits_one_event_per_row() {
3014 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3015 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
3016 .unwrap();
3017 rt.execute_query(
3018 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
3019 )
3020 .unwrap();
3021
3022 rt.execute_query("UPDATE items SET status = 'done'")
3023 .unwrap();
3024
3025 let events = queue_payloads(&rt, "evts");
3026 assert_eq!(events.len(), 3, "expected one update event per row");
3027 for event in &events {
3028 let obj = event.as_object().unwrap();
3029 assert_eq!(
3030 obj.get("op").and_then(crate::json::Value::as_str),
3031 Some("update")
3032 );
3033 }
3034 }
3035
3036 #[test]
3037 fn delete_single_row_emits_delete_event() {
3038 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3039 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
3040 .unwrap();
3041 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
3042 .unwrap();
3043
3044 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
3045
3046 let events = queue_payloads(&rt, "del_log");
3047 assert_eq!(events.len(), 1);
3048 let event = events[0].as_object().expect("event payload object");
3049 assert_eq!(
3050 event.get("op").and_then(crate::json::Value::as_str),
3051 Some("delete")
3052 );
3053 assert_eq!(
3054 event.get("collection").and_then(crate::json::Value::as_str),
3055 Some("users")
3056 );
3057 assert!(event
3058 .get("event_id")
3059 .and_then(crate::json::Value::as_str)
3060 .is_some_and(|v| !v.is_empty()));
3061 let before = event
3062 .get("before")
3063 .and_then(crate::json::Value::as_object)
3064 .expect("before must be an object for delete");
3065 assert_eq!(
3066 before.get("id").and_then(crate::json::Value::as_u64),
3067 Some(42)
3068 );
3069 assert_eq!(
3070 before.get("name").and_then(crate::json::Value::as_str),
3071 Some("Alice")
3072 );
3073 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
3074 }
3075
3076 #[test]
3077 fn multi_row_delete_emits_one_event_per_row() {
3078 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3079 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
3080 .unwrap();
3081 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
3082 .unwrap();
3083
3084 rt.execute_query("DELETE FROM items").unwrap();
3085
3086 let events = queue_payloads(&rt, "del_log");
3087 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
3088 for event in &events {
3089 let obj = event.as_object().unwrap();
3090 assert_eq!(
3091 obj.get("op").and_then(crate::json::Value::as_str),
3092 Some("delete")
3093 );
3094 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
3095 }
3096 }
3097
3098 #[test]
3099 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
3100 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3101 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
3102 .unwrap();
3103
3104 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3105 .unwrap();
3106 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3107
3108 let events = queue_payloads(&rt, "evts");
3109 assert!(
3110 events.is_empty(),
3111 "UPDATE-only filter must not emit INSERT or DELETE events"
3112 );
3113 }
3114
3115 #[test]
3118 fn suppress_events_on_insert_emits_no_events() {
3119 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3120 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3121 .unwrap();
3122
3123 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3124 .unwrap();
3125
3126 let events = queue_payloads(&rt, "evts");
3127 assert!(
3128 events.is_empty(),
3129 "SUPPRESS EVENTS must prevent INSERT events"
3130 );
3131 }
3132
3133 #[test]
3134 fn suppress_events_on_update_emits_no_events() {
3135 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3136 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3137 .unwrap();
3138 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3139 .unwrap();
3140 let _ = queue_payloads(&rt, "evts");
3142 rt.execute_query("QUEUE PURGE evts").unwrap();
3144
3145 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
3146 .unwrap();
3147
3148 let events = queue_payloads(&rt, "evts");
3149 assert!(
3150 events.is_empty(),
3151 "SUPPRESS EVENTS must prevent UPDATE events"
3152 );
3153 }
3154
3155 #[test]
3156 fn suppress_events_on_delete_emits_no_events() {
3157 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3158 rt.execute_query(
3159 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
3160 )
3161 .unwrap();
3162 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3163 .unwrap();
3164
3165 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
3166 .unwrap();
3167
3168 let events = queue_payloads(&rt, "evts");
3169 assert!(
3170 events.is_empty(),
3171 "SUPPRESS EVENTS must prevent DELETE events"
3172 );
3173 }
3174
3175 #[test]
3176 fn normal_insert_after_suppress_still_emits() {
3177 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3178 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3179 .unwrap();
3180
3181 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3182 .unwrap();
3183 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
3184 .unwrap();
3185
3186 let events = queue_payloads(&rt, "evts");
3187 assert_eq!(
3188 events.len(),
3189 1,
3190 "only the non-suppressed INSERT should emit"
3191 );
3192 assert_eq!(
3193 events[0].get("id").and_then(crate::json::Value::as_u64),
3194 Some(2)
3195 );
3196 }
3197}