Skip to main content

reddb_server/runtime/
impl_dml.rs

1//! DML execution: INSERT, UPDATE, DELETE via SQL AST
2//!
3//! Implements `execute_insert`, `execute_update`, and `execute_delete` on
4//! `RedDBRuntime`.  Each method translates the parsed AST into entity-level
5//! operations through the existing `RuntimeEntityPort` trait so that all
6//! cross-cutting concerns (WAL, indexing, replication) are automatically
7//! applied.
8
9use 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::*;
39// Insert-support and value-conversion helpers were extracted to
40// `impl_dml_support` (issue #1632); import them so existing call sites keep
41// using their bare names unchanged.
42use super::impl_dml_support::*;
43// RETURNING / update-analysis / update-target / claim helpers were extracted
44// to sibling modules (issue #1633); import them so existing call sites keep
45// using their bare names unchanged.
46use super::impl_dml_claim::*;
47use super::impl_dml_returning::*;
48use super::impl_dml_update_analysis::*;
49use super::impl_dml_update_target::*;
50// Chain-integrity / tenant-injection / batch-flush / crypto method families
51// were extracted to sibling modules (issue #1634). Their methods dispatch
52// through `self`, so no glob import is needed here. The SQL TTL free functions
53// are re-exported so both the execution paths below and
54// `impl_dml_support`'s `use super::impl_dml::{...}` keep resolving unchanged.
55pub(super) use super::impl_dml_ttl::{canonicalize_sql_ttl_metadata, resolve_sql_ttl_metadata_key};
56
57pub(super) const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
58pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
59pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
60
61#[derive(Clone)]
62pub(super) struct CompiledUpdateAssignment {
63    column: String,
64    expr: Expr,
65    compound_op: Option<BinOp>,
66    metadata_key: Option<&'static str>,
67    row_rule: Option<RowUpdateColumnRule>,
68}
69
70pub(super) struct CompiledUpdatePlan {
71    pub(super) static_field_assignments: Vec<(String, Value)>,
72    pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
73    dynamic_assignments: Vec<CompiledUpdateAssignment>,
74    row_contract_plan: Option<RowUpdateContractPlan>,
75    row_modified_columns: Vec<String>,
76    row_touches_unique_columns: bool,
77    /// The SET list names a column of a standalone UNIQUE index.
78    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    /// ADR 0067 (#1710): resolve the model of an unmarked bare-VALUES
344    /// INSERT from the catalog, making the marker rule real — *a model
345    /// marker exists only where it disambiguates what the catalog cannot
346    /// know.*
347    ///
348    /// `INSERT INTO c VALUES ({…})` has no column list and no model marker,
349    /// so the parser leaves `entity_type = Row` with an empty column list.
350    /// Only that exact shape is a candidate for inference — every other
351    /// INSERT (an explicit column list, or an explicit `DOCUMENT` / `NODE`
352    /// / `VECTOR` / … marker) is returned untouched.
353    ///
354    /// * existing **document** collection → rewritten to a `DOCUMENT`
355    ///   insert so the body routes through document creation, exactly the
356    ///   path the explicit marker takes;
357    /// * existing **non-document** collection → the bare form does not
358    ///   apply; reported with a model-oriented error;
359    /// * unknown collection → didactic error naming both recourses (the
360    ///   `DOCUMENT` assertion form or `CREATE DOCUMENT` first).
361    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    /// Resolve the model of an unmarked UPDATE from the catalog (ADR 0067,
397    /// #1711). The `DOCUMENTS` / `ROWS` / `KV` UPDATE markers were removed, so
398    /// an unmarked UPDATE parses to the `Rows` target; the catalog is the
399    /// single source of truth for the collection's model. An existing document
400    /// collection is rewritten to the `Documents` target (the runtime routes
401    /// body / patch semantics off the target the explicit marker used to set);
402    /// a KV collection to `Kv` so the key-immutability guard still fires. Table
403    /// and unknown collections keep the `Rows` default.
404    ///
405    /// Dotted assignment targets (`SET a.b.c = …`) parse for every target — the
406    /// model is not a parse-time fact — but they are legal only on a document
407    /// collection and rejected here with a clear model-oriented error off it.
408    fn resolve_unmarked_update_target(
409        &self,
410        query: &UpdateQuery,
411    ) -> RedDBResult<Option<UpdateQuery>> {
412        // NODES / EDGES are explicit, user-declared graph targets — never
413        // inferred. Dotted paths off a graph target are still off-model.
414        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    /// Execute INSERT INTO table [entity_type] (cols) VALUES (vals), ...
438    ///
439    /// Each row in `query.values` is zipped with `query.columns` to produce a
440    /// set of named fields, which is then dispatched based on entity_type.
441    pub fn execute_insert(
442        &self,
443        raw_query: &str,
444        query: &InsertQuery,
445    ) -> RedDBResult<RuntimeQueryResult> {
446        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
447        let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
448        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
449            self,
450            &query.table,
451        );
452        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
453        // CollectionContract gate (#49): single entry point for the
454        // operator's collection-level write rules. Today this is a
455        // no-op for INSERT (APPEND ONLY permits insert); routing
456        // through the gate now means future contract bits — versioned,
457        // vault-only writes — plug in once instead of per verb.
458        crate::runtime::collection_contract::CollectionContractGate::check(
459            self,
460            &query.table,
461            crate::runtime::collection_contract::MutationKind::Insert,
462        )?;
463        // ADR 0067 (#1710): catalog model inference for the unmarked
464        // bare-VALUES INSERT. `INSERT INTO c VALUES ({…})` carries no
465        // column list and no model marker (parsed as a Row insert with an
466        // empty column list); resolve the model from the catalog so an
467        // existing document collection routes to document creation and an
468        // unknown collection surfaces a didactic error. Runs before tenant
469        // injection so a rewritten document insert is tenant-scoped like
470        // the explicit `DOCUMENT` marker path.
471        let inferred_owned;
472        let query = match self.infer_unmarked_document_insert(query)? {
473            Some(rewritten) => {
474                inferred_owned = rewritten;
475                &inferred_owned
476            }
477            None => query,
478        };
479        // Phase 2.5.4 table-scoped tenancy: if the target table is
480        // tenant-scoped and the user didn't name the tenant column,
481        // auto-inject it with the thread-local `CURRENT_TENANT()`
482        // value. Explicit values must match the active transport/session
483        // tenant; an unbound platform caller may still write on behalf of a
484        // tenant. An unbound tenant on an implicit-fill path errors up front
485        // rather than producing a row the RLS policy would silently hide.
486        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            // Empty provider → resolve via the embeddings task pointer
511            // (ADR-0068 §5); an explicit `USING` overrides it. A modality-
512            // incapable provider fails didactically here.
513            let provider =
514                crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
515            // S3 / #711: planner-level provider gate. Runs before the
516            // local-model preflight and the API-key resolver so neither
517            // side-effect fires when policy denies.
518            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                // Issue #682 — pre-flight the local model registry before
522                // any row write. Missing model, uninstalled artifacts,
523                // wrong task, and disabled-feature failures surface as
524                // deterministic errors that leave the target collection
525                // untouched, satisfying the "no partial writes on
526                // embedding failure" criterion for the failure modes
527                // owned by the local provider.
528                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        // Ensure the collection exists (auto-create on first insert).
574        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        // One collection-scoped acquisition per upsert statement. Conflict
602        // discovery currently scans the collection, so the lock is not the
603        // dominant cost; it closes the check-then-insert race for every model
604        // until unique indexes become the atomic arbiter themselves.
605        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            // Issue #523 + #524: blockchain collections seal each row into the
629            // chain. When the caller omits the reserved columns, the engine
630            // auto-fills (#523). When the caller supplies any reserved column,
631            // the values are validated against the current tip and a mismatch
632            // surfaces a `BlockchainConflict:` error mapped to HTTP 409 (#524).
633            //
634            // The whole batch runs under a per-collection chain lock so two
635            // concurrent submitters can't both bind to the same prev_hash —
636            // the loser observes the advanced tip and gets 409 with the new
637            // tip so it can retry.
638            let chain_mode = crate::runtime::blockchain_kind::try_is_chain(&store, &query.table)?;
639            let _chain_lock_arc: Option<Arc<super::RmwRowLock>> = if chain_mode {
640                Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
641            } else {
642                None
643            };
644            let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
645
646            // Issue #525 — refuse new blocks if the chain has been marked
647            // `integrity = broken` until an admin clears the flag.
648            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            // Pull the tip from the in-memory cache; fall back to a one-time
657            // scan if the cache hasn't seen this collection yet (cold start
658            // after restart). Cache is updated below as rows are sealed.
659            let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
660                if chain_mode {
661                    let mut cache = self.inner.chain_tip_cache.lock();
662                    if let Some(existing) = cache.get(&query.table) {
663                        Some(existing.clone())
664                    } else if let Some(scanned) =
665                        crate::runtime::blockchain_kind::try_chain_tip_full(&store, &query.table)?
666                    {
667                        cache.insert(query.table.clone(), scanned.clone());
668                        Some(scanned)
669                    } else {
670                        None
671                    }
672                } else {
673                    None
674                };
675
676            let mut rows = Vec::with_capacity(effective_rows.len());
677            let mut accepted_row_fields: Vec<Vec<(String, Value)>> = Vec::new();
678            for row_values in &effective_rows {
679                if row_values.len() != query.columns.len() {
680                    return Err(RedDBError::Query(format!(
681                        "INSERT column count ({}) does not match value count ({})",
682                        query.columns.len(),
683                        row_values.len()
684                    )));
685                }
686                let (mut fields, mut metadata) =
687                    split_insert_metadata(self, &secret_columns, &query.columns, row_values)?;
688                if query
689                    .on_conflict
690                    .as_ref()
691                    .is_some_and(|clause| clause.target.is_none())
692                {
693                    fields = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
694                        self,
695                        &self.db(),
696                        &query.table,
697                    )
698                    .normalize_insert_fields(fields)?;
699                }
700                if let Some(clause) = &query.on_conflict {
701                    let conflict_id = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
702                        self,
703                        &self.db(),
704                        &query.table,
705                    )
706                    .row_uniqueness_conflict_id(&fields, clause.target.as_deref())?
707                    .filter(|conflict_id| {
708                        store.get(&query.table, *conflict_id).is_some_and(|entity| {
709                            !self.snapshot_manager().is_aborted(entity.xmin)
710                        })
711                    });
712                    if let Some(conflict_id) = conflict_id {
713                        if let Some(conflicting) = store.get(&query.table, conflict_id) {
714                            let conflicting_xid = conflicting.xmin;
715                            if self.snapshot_manager().is_active(conflicting_xid)
716                                && !self.own_transaction_xids().contains(&conflicting_xid)
717                            {
718                                return Err(RedDBError::Query(format!(
719                                    "serialization conflict: ON CONFLICT key in '{}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves",
720                                    query.table
721                                )));
722                            }
723                        }
724                        match &clause.action {
725                            OnConflictAction::DoNothing => continue,
726                            OnConflictAction::DoUpdate { assignments } => {
727                                let assignment_exprs = assignments
728                                    .iter()
729                                    .map(|(column, expr)| {
730                                        Ok((
731                                            column.clone(),
732                                            resolve_excluded_assignment(expr, &fields)?,
733                                        ))
734                                    })
735                                    .collect::<RedDBResult<Vec<_>>>()?;
736                                let literal_assignments = assignment_exprs
737                                    .iter()
738                                    .filter_map(|(column, expr)| {
739                                        fold_expr_to_value(expr.clone())
740                                            .ok()
741                                            .map(|value| (column.clone(), value))
742                                    })
743                                    .collect();
744                                let update = UpdateQuery {
745                                    table: query.table.clone(),
746                                    target: UpdateTarget::Rows,
747                                    compound_assignment_ops: vec![None; assignment_exprs.len()],
748                                    assignment_exprs,
749                                    assignments: literal_assignments,
750                                    where_expr: None,
751                                    filter: Some(Filter::Compare {
752                                        field: FieldRef::column("", "rid"),
753                                        op: CompareOp::Eq,
754                                        // Uniqueness locates a physical version; RQL rid is
755                                        // the stable logical identity across updates.
756                                        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                        // Caller is participating in the chain protocol —
829                        // every field must be supplied AND match the tip.
830                        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                                // Accept hex-encoded prev_hash so JSON / SQL
847                                // callers without literal-blob syntax can
848                                // still participate in the chain protocol.
849                                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                // Issue #522 — signed-writes verification. On collections
956                // created with `SIGNED_BY (...)` the row must carry valid
957                // `signer_pubkey` + `signature` reserved columns. Runs
958                // after chain_mode so canonical payload covers user-supplied
959                // fields only (blockchain reserved columns are filtered by
960                // `canonical_payload`; the two signed-writes reserved
961                // columns are split out before payload computation, then
962                // re-attached for storage). The blockchain + SIGNED_BY
963                // composition is owned by issue #526; we keep #522 to the
964                // non-chain path and let chain_mode collections punt to that
965                // slice rather than half-wire it here.
966                if crate::runtime::signed_writes_kind::try_is_signed(&store, &query.table)? {
967                    let (pk_col, sig_col, residual) =
968                        crate::runtime::signed_writes_kind::split_signature_fields(fields);
969                    let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
970                    let reg =
971                        crate::runtime::signed_writes_kind::try_registry(&store, &query.table)?;
972                    crate::runtime::signed_writes_kind::verify_row(
973                        &reg,
974                        pk_col.as_ref().map(|c| c.bytes.as_slice()),
975                        sig_col.as_ref().map(|c| c.bytes.as_slice()),
976                        &payload,
977                    )
978                    .map_err(crate::runtime::signed_writes_kind::map_error)?;
979                    fields = residual;
980                    // Round-trip the reserved columns with the value
981                    // type the caller supplied (Text/hex on the SQL path,
982                    // Blob on the binary path). Keeps SELECT and WHERE
983                    // predicates symmetric with the INSERT shape.
984                    if let Some(col) = pk_col {
985                        fields.push((
986                            crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
987                            col.raw_value,
988                        ));
989                    }
990                    if let Some(col) = sig_col {
991                        fields.push((
992                            crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
993                            col.raw_value,
994                        ));
995                    }
996                }
997                merge_with_clauses(
998                    &mut metadata,
999                    query.ttl_ms,
1000                    query.expires_at_ms,
1001                    &query.with_metadata,
1002                );
1003                if let Some(snaps) = returning_snapshots.as_mut() {
1004                    snaps.push(fields.clone());
1005                }
1006                accepted_row_fields.push(fields.clone());
1007                rows.push(CreateRowInput {
1008                    collection: query.table.clone(),
1009                    fields,
1010                    metadata,
1011                    node_links: Vec::new(),
1012                    vector_links: Vec::new(),
1013                });
1014            }
1015            let batch = CreateRowsBatchInput {
1016                collection: query.table.clone(),
1017                rows,
1018                suppress_events: query.suppress_events,
1019            };
1020            let outputs = if query
1021                .on_conflict
1022                .as_ref()
1023                .is_some_and(|clause| clause.target.is_none())
1024            {
1025                self.create_rows_batch_prevalidated_with_outputs(batch)?
1026            } else {
1027                self.create_rows_batch(batch)?
1028            };
1029            inserted_count += outputs.len() as u64;
1030            if query.auto_embed.is_some() {
1031                auto_embed_ids.extend(outputs.iter().map(|output| output.id));
1032            }
1033
1034            // Chain mode: commit the new tip to the in-memory cache only after
1035            // the batch persisted successfully. If the batch threw mid-way the
1036            // cache stays on the previous tip and the chain lock releases.
1037            if chain_mode {
1038                if let Some(new_tip) = chain_tip_full.as_ref() {
1039                    self.inner
1040                        .chain_tip_cache
1041                        .lock()
1042                        .insert(query.table.clone(), new_tip.clone());
1043                }
1044            }
1045
1046            // Hypertable chunk routing: if this table was declared via
1047            // CREATE HYPERTABLE, register each row's time-column value
1048            // with the registry so chunk metadata (bounds, row counts,
1049            // TTL eligibility) stays current. This is what lets
1050            // HYPERTABLE_PRUNE_CHUNKS answer real questions + lets the
1051            // retention daemon sweep expired chunks without scanning
1052            // every row.
1053            if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1054                let time_col = &spec.time_column;
1055                // Find the column's index in the INSERT column list.
1056                if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1057                    for row in &effective_rows {
1058                        if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1059                            if *n >= 0 {
1060                                let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1061                            }
1062                        } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1063                            let _ = self.inner.db.hypertables().route(&query.table, *n);
1064                        }
1065                    }
1066                }
1067            }
1068
1069            if let (Some(items), Some(snaps)) =
1070                (query.returning.as_ref(), returning_snapshots.take())
1071            {
1072                let snaps = row_insert_returning_snapshots(&outputs, snaps);
1073                let mut result = build_returning_result(items, &snaps, Some(&outputs));
1074                result
1075                    .records
1076                    .splice(0..0, conflict_returning_records.drain(..));
1077                returning_result = Some(result);
1078            }
1079        } else {
1080            // Issue #419: surface the inserted entity id on every INSERT path.
1081            // For Node/Edge/Vector/Document/Kv we now keep each CreateEntityOutput
1082            // so a RETURNING clause (and the unconditional inserted_ids list,
1083            // below) can expose the engine-assigned id. TimeSeries (the row
1084            // branch in this else) still returns the not-supported error
1085            // because create_timeseries_point isn't plumbed through this fn.
1086            let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1087                Vec::with_capacity(effective_rows.len());
1088            let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1089            {
1090                Vec::with_capacity(effective_rows.len())
1091            } else {
1092                Vec::new()
1093            };
1094            if matches!(
1095                query.entity_type,
1096                InsertEntityType::Node | InsertEntityType::Edge
1097            ) {
1098                enum PreparedGraphInsert {
1099                    Node {
1100                        fields: Vec<(String, Value)>,
1101                        input: CreateNodeInput,
1102                    },
1103                    Edge {
1104                        fields: Vec<(String, Value)>,
1105                        input: CreateEdgeInput,
1106                    },
1107                }
1108
1109                let mut prepared = Vec::with_capacity(effective_rows.len());
1110                let mut accepted_graph_fields: Vec<Vec<(String, Value)>> = Vec::new();
1111                for row_values in &effective_rows {
1112                    if row_values.len() != query.columns.len() {
1113                        return Err(RedDBError::Query(format!(
1114                            "INSERT column count ({}) does not match value count ({})",
1115                            query.columns.len(),
1116                            row_values.len()
1117                        )));
1118                    }
1119
1120                    match query.entity_type {
1121                        InsertEntityType::Node => {
1122                            let (node_values, mut metadata) = split_insert_metadata(
1123                                self,
1124                                &secret_columns,
1125                                &query.columns,
1126                                row_values,
1127                            )?;
1128                            if let Some(clause) = &query.on_conflict {
1129                                if let Some(conflict_id) = self
1130                                    .on_conflict_unique_index_conflict_id(
1131                                        &query.table,
1132                                        &node_values,
1133                                        clause.target.as_deref(),
1134                                    )?
1135                                {
1136                                    match &clause.action {
1137                                        OnConflictAction::DoNothing => continue,
1138                                        OnConflictAction::DoUpdate { assignments } => {
1139                                            let updated = self
1140                                                .execute_unique_index_conflict_update(
1141                                                    raw_query,
1142                                                    query,
1143                                                    conflict_id,
1144                                                    UpdateTarget::Nodes,
1145                                                    &node_values,
1146                                                    assignments,
1147                                                )?;
1148                                            inserted_count += updated.affected_rows;
1149                                            conflict_returning_records
1150                                                .extend(updated.result.records);
1151                                            continue;
1152                                        }
1153                                    }
1154                                }
1155                                if matches!(&clause.action, OnConflictAction::DoNothing)
1156                                    && self.has_on_conflict_unique_index_conflict_with_fields(
1157                                        &query.table,
1158                                        &node_values,
1159                                        &accepted_graph_fields,
1160                                        clause.target.as_deref(),
1161                                    )?
1162                                {
1163                                    continue;
1164                                }
1165                            }
1166                            merge_with_clauses(
1167                                &mut metadata,
1168                                query.ttl_ms,
1169                                query.expires_at_ms,
1170                                &query.with_metadata,
1171                            );
1172                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1173                            apply_collection_default_ttl_metadata(
1174                                self,
1175                                &query.table,
1176                                &mut metadata,
1177                            );
1178                            let (columns, values) = pairwise_columns_values(&node_values);
1179                            let label = find_column_value_string(&columns, &values, "label")?;
1180                            let node_type =
1181                                find_column_value_opt_string(&columns, &values, "node_type");
1182                            let properties = extract_remaining_properties(
1183                                &columns,
1184                                &values,
1185                                &["label", "node_type"],
1186                            );
1187                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1188                                properties.iter().map(|(key, _)| key.as_str()),
1189                                &format!("node '{}'", query.table),
1190                            )?;
1191                            accepted_graph_fields.push(node_values.clone());
1192                            prepared.push(PreparedGraphInsert::Node {
1193                                fields: node_values,
1194                                input: CreateNodeInput {
1195                                    collection: query.table.clone(),
1196                                    label,
1197                                    node_type,
1198                                    properties,
1199                                    metadata,
1200                                    embeddings: Vec::new(),
1201                                    table_links: Vec::new(),
1202                                    node_links: Vec::new(),
1203                                },
1204                            });
1205                        }
1206                        InsertEntityType::Edge => {
1207                            let (edge_values, mut metadata) = split_insert_metadata(
1208                                self,
1209                                &secret_columns,
1210                                &query.columns,
1211                                row_values,
1212                            )?;
1213                            if let Some(clause) = &query.on_conflict {
1214                                if let Some(conflict_id) = self
1215                                    .on_conflict_unique_index_conflict_id(
1216                                        &query.table,
1217                                        &edge_values,
1218                                        clause.target.as_deref(),
1219                                    )?
1220                                {
1221                                    match &clause.action {
1222                                        OnConflictAction::DoNothing => continue,
1223                                        OnConflictAction::DoUpdate { assignments } => {
1224                                            let updated = self
1225                                                .execute_unique_index_conflict_update(
1226                                                    raw_query,
1227                                                    query,
1228                                                    conflict_id,
1229                                                    UpdateTarget::Edges,
1230                                                    &edge_values,
1231                                                    assignments,
1232                                                )?;
1233                                            inserted_count += updated.affected_rows;
1234                                            conflict_returning_records
1235                                                .extend(updated.result.records);
1236                                            continue;
1237                                        }
1238                                    }
1239                                }
1240                                if matches!(&clause.action, OnConflictAction::DoNothing)
1241                                    && self.has_on_conflict_unique_index_conflict_with_fields(
1242                                        &query.table,
1243                                        &edge_values,
1244                                        &accepted_graph_fields,
1245                                        clause.target.as_deref(),
1246                                    )?
1247                                {
1248                                    continue;
1249                                }
1250                            }
1251                            merge_with_clauses(
1252                                &mut metadata,
1253                                query.ttl_ms,
1254                                query.expires_at_ms,
1255                                &query.with_metadata,
1256                            );
1257                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1258                            apply_collection_default_ttl_metadata(
1259                                self,
1260                                &query.table,
1261                                &mut metadata,
1262                            );
1263                            let (columns, values) = pairwise_columns_values(&edge_values);
1264                            let label = find_column_value_string(&columns, &values, "label")?;
1265                            ensure_non_tree_structural_edge_label(&label)?;
1266                            let from_id = resolve_edge_endpoint_any(
1267                                self.inner.db.store().as_ref(),
1268                                &query.table,
1269                                &columns,
1270                                &values,
1271                                &["from_rid", "from"],
1272                            )?;
1273                            let to_id = resolve_edge_endpoint_any(
1274                                self.inner.db.store().as_ref(),
1275                                &query.table,
1276                                &columns,
1277                                &values,
1278                                &["to_rid", "to"],
1279                            )?;
1280                            let weight = find_column_value_f32_opt(&columns, &values, "weight");
1281                            let properties = extract_remaining_properties(
1282                                &columns,
1283                                &values,
1284                                &["label", "from_rid", "to_rid", "from", "to", "weight"],
1285                            );
1286                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1287                                properties.iter().map(|(key, _)| key.as_str()),
1288                                &format!("edge '{}'", query.table),
1289                            )?;
1290                            accepted_graph_fields.push(edge_values.clone());
1291                            prepared.push(PreparedGraphInsert::Edge {
1292                                fields: edge_values,
1293                                input: CreateEdgeInput {
1294                                    collection: query.table.clone(),
1295                                    label,
1296                                    from: EntityId::new(from_id),
1297                                    to: EntityId::new(to_id),
1298                                    weight,
1299                                    properties,
1300                                    metadata,
1301                                },
1302                            });
1303                        }
1304                        _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1305                    }
1306                }
1307
1308                ensure_graph_insert_contract(self, &query.table)?;
1309                let mut batch = self.inner.db.batch();
1310                let mut graph_index_fields: Vec<Vec<(String, Value)>> =
1311                    Vec::with_capacity(prepared.len());
1312                for item in prepared {
1313                    match item {
1314                        PreparedGraphInsert::Node { fields, input } => {
1315                            if query.returning.is_some() {
1316                                returning_field_snaps.push(fields.clone());
1317                            }
1318                            graph_index_fields.push(fields);
1319                            let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1320                            batch = batch.add_node_with_type(
1321                                input.collection,
1322                                input.label,
1323                                node_type,
1324                                input.properties.into_iter().collect(),
1325                                input.metadata.into_iter().collect(),
1326                            );
1327                        }
1328                        PreparedGraphInsert::Edge { fields, input } => {
1329                            if query.returning.is_some() {
1330                                returning_field_snaps.push(fields.clone());
1331                            }
1332                            graph_index_fields.push(fields);
1333                            batch = batch.add_edge(
1334                                input.collection,
1335                                input.label,
1336                                input.from,
1337                                input.to,
1338                                input.weight.unwrap_or(1.0),
1339                                input.properties.into_iter().collect(),
1340                                input.metadata.into_iter().collect(),
1341                            );
1342                        }
1343                    }
1344                }
1345                let batch_result = batch
1346                    .execute()
1347                    .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1348                let (ids, entity_kind) = match query.entity_type {
1349                    InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1350                    InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1351                    _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1352                };
1353                for id in &ids {
1354                    self.stamp_xmin_if_in_txn(&query.table, *id);
1355                }
1356                if !graph_index_fields.is_empty() {
1357                    let index_rows: Vec<_> = ids
1358                        .iter()
1359                        .zip(graph_index_fields)
1360                        .map(|(id, fields)| (*id, fields))
1361                        .collect();
1362                    self.inner
1363                        .index_store
1364                        .index_entity_insert_batch(&query.table, &index_rows)
1365                        .map_err(RedDBError::Internal)?;
1366                }
1367                if query.returning.is_some() {
1368                    returning_field_snaps = graph_insert_returning_snapshots(
1369                        self.inner.db.store().as_ref(),
1370                        &query.table,
1371                        &ids,
1372                    );
1373                }
1374                self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1375                let store = self.inner.db.store();
1376                entity_outputs.extend(ids.iter().map(|id| {
1377                    crate::application::entity::CreateEntityOutput {
1378                        id: *id,
1379                        entity: store.get(&query.table, *id),
1380                    }
1381                }));
1382                inserted_count += ids.len() as u64;
1383            } else {
1384                for row_values in &effective_rows {
1385                    if row_values.len() != query.columns.len() {
1386                        return Err(RedDBError::Query(format!(
1387                            "INSERT column count ({}) does not match value count ({})",
1388                            query.columns.len(),
1389                            row_values.len()
1390                        )));
1391                    }
1392
1393                    match query.entity_type {
1394                        InsertEntityType::Row => {
1395                            if query.returning.is_some() {
1396                                return Err(RedDBError::Query(
1397                                "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1398                                    .to_string(),
1399                            ));
1400                            }
1401                            let (fields, mut metadata) = split_insert_metadata(
1402                                self,
1403                                &secret_columns,
1404                                &query.columns,
1405                                row_values,
1406                            )?;
1407                            merge_with_clauses(
1408                                &mut metadata,
1409                                query.ttl_ms,
1410                                query.expires_at_ms,
1411                                &query.with_metadata,
1412                            );
1413                            self.insert_timeseries_point(&query.table, fields, metadata)?;
1414                        }
1415                        InsertEntityType::Node | InsertEntityType::Edge => {
1416                            unreachable!("NODE and EDGE are handled by the prepared graph path")
1417                        }
1418                        InsertEntityType::Vector => {
1419                            let (vector_values, mut metadata) = split_insert_metadata(
1420                                self,
1421                                &secret_columns,
1422                                &query.columns,
1423                                row_values,
1424                            )?;
1425                            merge_with_clauses(
1426                                &mut metadata,
1427                                query.ttl_ms,
1428                                query.expires_at_ms,
1429                                &query.with_metadata,
1430                            );
1431                            let (columns, values) = pairwise_columns_values(&vector_values);
1432                            let dense = find_column_value_vec_f32_any(
1433                                &columns,
1434                                &values,
1435                                &["dense", "embedding"],
1436                            )?;
1437                            merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1438                            let content =
1439                                find_column_value_opt_string(&columns, &values, "content");
1440                            if query.returning.is_some() {
1441                                returning_field_snaps.push(vector_values.clone());
1442                            }
1443                            let input = CreateVectorInput {
1444                                collection: query.table.clone(),
1445                                dense,
1446                                content,
1447                                metadata,
1448                                link_row: None,
1449                                link_node: None,
1450                            };
1451                            entity_outputs.push(self.create_vector(input)?);
1452                        }
1453                        InsertEntityType::Document => {
1454                            let (document_values, mut metadata) = split_insert_metadata(
1455                                self,
1456                                &secret_columns,
1457                                &query.columns,
1458                                row_values,
1459                            )?;
1460                            let (columns, values) = pairwise_columns_values(&document_values);
1461                            let body = find_document_body_json(&columns, &values)?;
1462                            let conflict_fields =
1463                                document_on_conflict_fields(&document_values, &body)?;
1464                            if let Some(clause) = &query.on_conflict {
1465                                if let Some(conflict_id) = self
1466                                    .on_conflict_unique_index_conflict_id(
1467                                        &query.table,
1468                                        &conflict_fields,
1469                                        clause.target.as_deref(),
1470                                    )?
1471                                {
1472                                    match &clause.action {
1473                                        OnConflictAction::DoNothing => continue,
1474                                        OnConflictAction::DoUpdate { assignments } => {
1475                                            let updated = self
1476                                                .execute_unique_index_conflict_update(
1477                                                    raw_query,
1478                                                    query,
1479                                                    conflict_id,
1480                                                    UpdateTarget::Documents,
1481                                                    &conflict_fields,
1482                                                    assignments,
1483                                                )?;
1484                                            inserted_count += updated.affected_rows;
1485                                            conflict_returning_records
1486                                                .extend(updated.result.records);
1487                                            continue;
1488                                        }
1489                                    }
1490                                }
1491                            }
1492                            merge_with_clauses(
1493                                &mut metadata,
1494                                query.ttl_ms,
1495                                query.expires_at_ms,
1496                                &query.with_metadata,
1497                            );
1498                            let input = CreateDocumentInput {
1499                                collection: query.table.clone(),
1500                                body,
1501                                metadata,
1502                                node_links: Vec::new(),
1503                                vector_links: Vec::new(),
1504                            };
1505                            let output = self.create_document(input)?;
1506                            if query.returning.is_some() {
1507                                let fields = output
1508                                    .entity
1509                                    .as_ref()
1510                                    .map(entity_row_fields_snapshot)
1511                                    .filter(|fields| !fields.is_empty())
1512                                    .unwrap_or(document_values);
1513                                returning_field_snaps.push(fields);
1514                            }
1515                            entity_outputs.push(output);
1516                        }
1517                        InsertEntityType::Kv => {
1518                            let (kv_values, mut metadata) = split_insert_metadata(
1519                                self,
1520                                &secret_columns,
1521                                &query.columns,
1522                                row_values,
1523                            )?;
1524                            merge_with_clauses(
1525                                &mut metadata,
1526                                query.ttl_ms,
1527                                query.expires_at_ms,
1528                                &query.with_metadata,
1529                            );
1530                            let (columns, values) = pairwise_columns_values(&kv_values);
1531                            let key = find_column_value_string(&columns, &values, "key")?;
1532                            let value = find_column_value(&columns, &values, "value")?;
1533                            if query.returning.is_some() {
1534                                returning_field_snaps.push(kv_values.clone());
1535                            }
1536                            let input = CreateKvInput {
1537                                collection: query.table.clone(),
1538                                key,
1539                                value,
1540                                metadata,
1541                            };
1542                            entity_outputs.push(self.create_kv(input)?);
1543                        }
1544                    }
1545
1546                    inserted_count += 1;
1547                }
1548            }
1549
1550            if query.auto_embed.is_some() {
1551                auto_embed_ids.extend(entity_outputs.iter().map(|output| output.id));
1552            }
1553
1554            if let Some(items) = query.returning.as_ref() {
1555                let mut result =
1556                    build_returning_result(items, &returning_field_snaps, Some(&entity_outputs));
1557                result
1558                    .records
1559                    .splice(0..0, conflict_returning_records.drain(..));
1560                returning_result = Some(result);
1561            }
1562        }
1563
1564        // Auto-embed pipeline: batch-embed fields across all inserted rows via AiBatchClient.
1565        if let Some((provider, model, connection, fields)) = resolved_auto_embed.as_ref() {
1566            let store = self.inner.db.store();
1567            let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1568            let (api_key, api_base) = connection
1569                .as_ref()
1570                .map(|connection| (connection.api_key.as_str(), connection.api_base.as_str()))
1571                .unwrap_or(("", ""));
1572
1573            // Read only this INSERT's returned IDs. A pre-write statement
1574            // snapshot correctly excludes newly committed rows; a fresh/global
1575            // scan could instead pick another writer's rows or conflict skips.
1576            let recent: Vec<_> = auto_embed_ids
1577                .iter()
1578                .filter_map(|id| store.get(&query.table, *id))
1579                .collect();
1580
1581            // Collector phase: (entity_index, combined_text) for rows that have non-empty fields.
1582            let entity_combos: Vec<(usize, String)> = recent
1583                .iter()
1584                .enumerate()
1585                .filter_map(|(i, entity)| {
1586                    if let EntityData::Row(ref row) = entity.data {
1587                        let texts: Vec<String> = fields
1588                            .iter()
1589                            .filter_map(|field| match row.get_field(field) {
1590                                Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1591                                _ => None,
1592                            })
1593                            .collect();
1594                        if !texts.is_empty() {
1595                            return Some((i, texts.join(" ")));
1596                        }
1597                    }
1598                    None
1599                })
1600                .collect();
1601
1602            if !entity_combos.is_empty() {
1603                // Batch phase: single provider round-trip for all rows.
1604                let batch_texts: Vec<String> =
1605                    entity_combos.iter().map(|(_, t)| t.clone()).collect();
1606
1607                // Issue #682 — when the provider is `local`, bypass
1608                // AiBatchClient (which is HTTP-only) and dispatch
1609                // directly through the in-process local embedding
1610                // backend. All texts go in one call, mirroring the
1611                // single-round-trip shape of the remote path. The
1612                // local backend does not perform intra-batch dedup —
1613                // each input position gets its own row in the output
1614                // — which keeps the per-row "create_vector" loop
1615                // below correct without additional fan-out logic.
1616                let embeddings = if is_local_provider {
1617                    let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1618                        &self.inner.db,
1619                        model,
1620                        batch_texts,
1621                    )?;
1622                    response.embeddings
1623                } else {
1624                    let batch_client =
1625                        crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1626
1627                    match tokio::runtime::Handle::try_current() {
1628                        Ok(handle) => tokio::task::block_in_place(|| {
1629                            handle.block_on(batch_client.embed_batch_at(
1630                                provider,
1631                                model,
1632                                api_key,
1633                                api_base,
1634                                batch_texts,
1635                            ))
1636                        }),
1637                        Err(_) => {
1638                            return Err(RedDBError::Query(
1639                                "AUTO EMBED requires a Tokio runtime context".to_string(),
1640                            ));
1641                        }
1642                    }
1643                    .map_err(|e| RedDBError::Query(e.to_string()))?
1644                };
1645
1646                // Distribute phase: persist one vector per non-empty embedding.
1647                for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1648                    if dense.is_empty() {
1649                        continue;
1650                    }
1651                    self.create_vector(CreateVectorInput {
1652                        collection: query.table.clone(),
1653                        dense,
1654                        content: Some(combined.clone()),
1655                        metadata: Vec::new(),
1656                        link_row: None,
1657                        link_node: None,
1658                    })?;
1659                }
1660            }
1661        }
1662
1663        if inserted_count > 0 {
1664            self.note_table_write(&query.table);
1665        }
1666
1667        let mut result = RuntimeQueryResult::dml_result(
1668            raw_query.to_string(),
1669            inserted_count,
1670            "insert",
1671            "runtime-dml",
1672        );
1673        if let Some(returning) = returning_result {
1674            result.result = returning;
1675        }
1676        Ok(result)
1677    }
1678
1679    fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1680        let Some(auth_store) = self.inner.auth_store.read().clone() else {
1681            return Ok(());
1682        };
1683        if !auth_store.iam_authorization_enabled() {
1684            return Ok(());
1685        }
1686        let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1687            return Ok(());
1688        };
1689
1690        let tenant = crate::runtime::impl_core::current_tenant();
1691        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1692        let request = crate::auth::ColumnAccessRequest {
1693            action: "insert".to_string(),
1694            schema: None,
1695            table: query.table.clone(),
1696            columns: query.columns.clone(),
1697        };
1698        let ctx = crate::auth::policies::EvalContext {
1699            principal_tenant: tenant.clone(),
1700            current_tenant: tenant,
1701            peer_ip: None,
1702            mfa_present: false,
1703            now_ms: crate::auth::now_ms(),
1704            principal_is_admin_role: role == crate::auth::Role::Admin,
1705            principal_is_platform_scoped: principal.tenant.is_none(),
1706        };
1707
1708        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1709        let table_allowed = matches!(
1710            outcome.table_decision,
1711            crate::auth::policies::Decision::Allow { .. }
1712                | crate::auth::policies::Decision::AdminBypass
1713        );
1714        if !table_allowed {
1715            return Err(RedDBError::Query(format!(
1716                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1717                outcome.table_resource.kind, outcome.table_resource.name
1718            )));
1719        }
1720        if let Some(denied) = outcome.first_denied_column() {
1721            return Err(RedDBError::Query(format!(
1722                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1723                denied.resource.kind, denied.resource.name
1724            )));
1725        }
1726
1727        Ok(())
1728    }
1729
1730    pub(crate) fn insert_timeseries_point(
1731        &self,
1732        collection: &str,
1733        mut fields: Vec<(String, Value)>,
1734        mut metadata: Vec<(String, MetadataValue)>,
1735    ) -> RedDBResult<EntityId> {
1736        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, collection)?;
1737        crate::runtime::mutation::enforce_tenant_fields(self, collection, &mut fields)?;
1738        apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1739
1740        let (columns, values) = pairwise_columns_values(&fields);
1741        validate_timeseries_insert_columns(&columns)?;
1742
1743        // Issue #577 — AnalyticsSchemaRegistry hook. If the row carries
1744        // an `event_name` whose schema is registered, validate the
1745        // `payload` JSON against it BEFORE any write side-effect. On
1746        // failure we return a typed error and the row is not
1747        // persisted. When no schema is registered for the event name
1748        // (or no `event_name` column is supplied at all) we fall
1749        // through to the normal write path for back-compat with
1750        // existing timeseries rows.
1751        let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1752        let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1753        if let Some(event_name) = event_name_opt.as_deref() {
1754            let store_for_schema = self.inner.db.store();
1755            if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1756                .is_some()
1757            {
1758                let payload_json = payload_opt.as_deref().unwrap_or("{}");
1759                super::analytics_schema_registry::validate(
1760                    store_for_schema.as_ref(),
1761                    event_name,
1762                    payload_json,
1763                )
1764                .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1765            }
1766        }
1767
1768        // `metric` is required by the existing timeseries write path;
1769        // when an analytics-style row supplies `event_name` but not
1770        // `metric`, fall back to the event name so the storage path
1771        // still has a non-empty metric tag.
1772        let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1773            Some(m) => m,
1774            None => event_name_opt.clone().ok_or_else(|| {
1775                RedDBError::Query(
1776                    "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1777                )
1778            })?,
1779        };
1780        // `value` is optional for analytics-event rows (which are
1781        // semantically counts of 1); default to 1.0 when missing so
1782        // analytics inserts don't have to fabricate a metric value.
1783        let value = match find_column_value_opt_string(&columns, &values, "value") {
1784            Some(s) => s.parse::<f64>().unwrap_or(1.0),
1785            None => columns
1786                .iter()
1787                .position(|c| c.eq_ignore_ascii_case("value"))
1788                .and_then(|i| match &values[i] {
1789                    Value::Float(f) => Some(*f),
1790                    Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1791                    Value::UnsignedInteger(n) => Some(*n as f64),
1792                    _ => None,
1793                })
1794                .unwrap_or(1.0),
1795        };
1796        let timestamp_ns =
1797            find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1798        let mut tags = find_timeseries_tags(&columns, &values)?;
1799        if let Some(ref name) = event_name_opt {
1800            tags.entry("event_name".to_string())
1801                .or_insert_with(|| name.clone());
1802        }
1803        if let Some(ref payload) = payload_opt {
1804            tags.entry("payload".to_string())
1805                .or_insert_with(|| payload.clone());
1806        }
1807        let fields = extract_remaining_properties(
1808            &columns,
1809            &values,
1810            &[
1811                "metric",
1812                "value",
1813                "tags",
1814                "timestamp",
1815                "timestamp_ns",
1816                "time",
1817                "event_name",
1818                "payload",
1819            ],
1820        );
1821        let index_fields = fields.clone();
1822        let _segment_reservation = self.admit_non_evictable_growth(
1823            crate::storage::memory_pools::MemoryPool::SegmentArena,
1824            &format!("insert into {collection}"),
1825            crate::runtime::memory_admission::estimate_timeseries_point_growth(
1826                &metric, &tags, &fields,
1827            ),
1828        )?;
1829        let indexed_columns = self
1830            .inner
1831            .index_store
1832            .list_indices(collection)
1833            .into_iter()
1834            .flat_map(|index| index.columns)
1835            .collect::<Vec<_>>();
1836        let _index_reservation = if indexed_columns.is_empty() {
1837            None
1838        } else {
1839            Some(self.admit_non_evictable_growth(
1840                crate::storage::memory_pools::MemoryPool::IndexMemory,
1841                &format!("index insert into {collection}"),
1842                crate::runtime::memory_admission::estimate_index_growth(
1843                    std::slice::from_ref(&index_fields),
1844                    &indexed_columns,
1845                ),
1846            )?)
1847        };
1848        let series_id = super::impl_timeseries::intern_timeseries_series(
1849            self.inner.db.store().as_ref(),
1850            collection,
1851            &metric,
1852            &tags,
1853        )?;
1854
1855        let mut entity = UnifiedEntity::new(
1856            EntityId::new(0),
1857            EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1858                series: collection.to_string(),
1859                metric: metric.clone(),
1860            })),
1861            EntityData::TimeSeries(crate::storage::TimeSeriesData {
1862                metric,
1863                series_id: Some(series_id),
1864                timestamp_ns,
1865                value,
1866                tags: HashMap::new(),
1867                fields: fields.into_iter().collect(),
1868            }),
1869        );
1870        if !crate::runtime::rls_injection::entity_passes_rls_action(
1871            self,
1872            collection,
1873            reddb_rql::ast::PolicyAction::Insert,
1874            &entity,
1875        ) {
1876            return Err(RedDBError::Query(format!(
1877                "insert into collection '{collection}' denied by row-level security"
1878            )));
1879        }
1880        // MVCC #30: stamp xmin with the active tx xid (inside a tx)
1881        // or an autocommit xid (allocated and committed up-front so
1882        // future snapshots see the row as soon as it lands).
1883        let writer_xid = match self.current_xid() {
1884            Some(xid) => xid,
1885            None => {
1886                let mgr = self.snapshot_manager();
1887                let xid = mgr.begin();
1888                mgr.commit(xid);
1889                xid
1890            }
1891        };
1892        entity.set_xmin(writer_xid);
1893
1894        let store = self.inner.db.store();
1895        let id = store
1896            .insert_auto(collection, entity)
1897            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1898        self.inner
1899            .index_store
1900            .index_entity_insert(collection, id, &index_fields)
1901            .map_err(RedDBError::Internal)?;
1902
1903        if !metadata.is_empty() {
1904            let _ = store.set_metadata(
1905                collection,
1906                id,
1907                Metadata::with_fields(metadata.into_iter().collect()),
1908            );
1909        }
1910
1911        let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1912
1913        self.cdc_emit(
1914            crate::replication::cdc::ChangeOperation::Insert,
1915            collection,
1916            id.raw(),
1917            "timeseries",
1918        );
1919
1920        Ok(id)
1921    }
1922
1923    /// Execute UPDATE table SET col=val, ... WHERE filter
1924    ///
1925    /// Scans the target collection, evaluates the WHERE filter against each
1926    /// record, and patches every matching entity.
1927    pub fn execute_update(
1928        &self,
1929        raw_query: &str,
1930        query: &UpdateQuery,
1931    ) -> RedDBResult<RuntimeQueryResult> {
1932        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1933        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
1934            self,
1935            &query.table,
1936        );
1937        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
1938        // Issue #523 — blockchain collections are immutable. Reject before
1939        // RLS / RETURNING work so the operator sees a clean 409-mapped
1940        // error instead of a partially-applied mutation surface.
1941        if crate::runtime::blockchain_kind::try_is_chain(
1942            self.inner.db.store().as_ref(),
1943            &query.table,
1944        )? {
1945            return Err(RedDBError::InvalidOperation(format!(
1946                "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1947                query.table
1948            )));
1949        }
1950        // Queue-shaped CLAIM (ADR 0020, #1609): a CLAIM on a queue collection
1951        // is a QueueLifecycle delivery acquisition, not a raw row UPDATE.
1952        // Route it through the lifecycle seam before the table-shaped
1953        // contract / RLS gates below (which would reject an UPDATE against a
1954        // queue's declared model) so QueueLifecycle stays the sole authority
1955        // for delivery state.
1956        if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1957            return self.execute_queue_shaped_claim(raw_query, query);
1958        }
1959        // ADR 0067 (#1711): the DOCUMENTS / ROWS / KV UPDATE markers were
1960        // removed. Resolve the model of an unmarked UPDATE from the catalog so
1961        // a document collection routes to document semantics and a KV
1962        // collection keeps its key-immutability guard, and gate dotted SET
1963        // targets that only a document body can satisfy. Runs before the
1964        // contract / RLS gates so every downstream path — the inner scan,
1965        // RETURNING — observes the resolved target.
1966        let inferred_owned;
1967        let query = match self.resolve_unmarked_update_target(query)? {
1968            Some(rewritten) => {
1969                inferred_owned = rewritten;
1970                &inferred_owned
1971            }
1972            None => query,
1973        };
1974        // CollectionContract gate (#50): runs the APPEND ONLY guard
1975        // (and any future contract bits) before RLS / RETURNING work
1976        // so the operator's immutability declaration is honoured
1977        // uniformly and the error message points at the DDL rather
1978        // than at a downstream symptom.
1979        crate::runtime::collection_contract::CollectionContractGate::check(
1980            self,
1981            &query.table,
1982            crate::runtime::collection_contract::MutationKind::Update,
1983        )?;
1984        ensure_update_target_contract(self, &query.table, query.target)?;
1985        ensure_kv_key_update_target_allowed(query)?;
1986        ensure_graph_identity_update_target_allowed(query)?;
1987
1988        // Apply RLS augmentation first so every downstream path — plain
1989        // UPDATE, UPDATE...RETURNING, the inner scan — observes the
1990        // same policy-filtered target set. This prevents RETURNING
1991        // from ever exposing rows the UPDATE policy would have
1992        // denied.
1993        let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1994        let augmented_query: UpdateQuery;
1995        let effective_query: &UpdateQuery = if rls_gated {
1996            let update_filter = crate::runtime::impl_core::rls_policy_filter(
1997                self,
1998                &query.table,
1999                reddb_rql::ast::PolicyAction::Update,
2000            );
2001            let Some(mut policy) = update_filter else {
2002                // No admitting policy: zero rows affected, empty
2003                // RETURNING (never leak rows the caller can't touch).
2004                let mut response = RuntimeQueryResult::dml_result(
2005                    raw_query.to_string(),
2006                    0,
2007                    "update",
2008                    "runtime-dml-rls",
2009                );
2010                if let Some(items) = query.returning.clone() {
2011                    response.result = build_returning_result(&items, &[], None);
2012                }
2013                return Ok(response);
2014            };
2015            if query.claim_limit.is_some() {
2016                let read_filter = crate::runtime::impl_core::rls_policy_filter(
2017                    self,
2018                    &query.table,
2019                    reddb_rql::ast::PolicyAction::Select,
2020                );
2021                let Some(read_policy) = read_filter else {
2022                    let mut response = RuntimeQueryResult::dml_result(
2023                        raw_query.to_string(),
2024                        0,
2025                        "update",
2026                        "runtime-dml-rls",
2027                    );
2028                    if let Some(items) = query.returning.clone() {
2029                        response.result = build_returning_result(&items, &[], None);
2030                    }
2031                    return Ok(response);
2032                };
2033                policy = reddb_rql::ast::Filter::And(Box::new(read_policy), Box::new(policy));
2034            }
2035            let mut augmented = query.clone();
2036            augmented.filter = Some(match augmented.filter.take() {
2037                Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2038                None => policy,
2039            });
2040            augmented_query = augmented;
2041            &augmented_query
2042        } else {
2043            query
2044        };
2045
2046        // RETURNING wraps the inner executor and uses the touched-id
2047        // list the inner reports so the post-image reflects exactly
2048        // the rows the UPDATE actually mutated (not whatever a
2049        // separate SELECT might have observed).
2050        if let Some(items) = effective_query.returning.clone() {
2051            let mut inner_query = effective_query.clone();
2052            inner_query.returning = None;
2053            let (mut response, touched_ids) =
2054                self.execute_update_inner_tracked(raw_query, &inner_query)?;
2055
2056            let mut snapshots = if matches!(
2057                effective_query.target,
2058                UpdateTarget::Nodes | UpdateTarget::Edges
2059            ) {
2060                graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
2061            } else {
2062                super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
2063                    .row_snapshots(&touched_ids)?
2064            };
2065            if matches!(effective_query.target, UpdateTarget::Kv) {
2066                restore_kv_returning_keys(
2067                    self,
2068                    &effective_query.table,
2069                    &touched_ids,
2070                    &mut snapshots,
2071                );
2072            }
2073
2074            response.result = build_returning_result(&items, &snapshots, None);
2075            response.engine = "runtime-dml-returning";
2076            return Ok(response);
2077        }
2078
2079        self.execute_update_inner(raw_query, effective_query)
2080    }
2081
2082    /// Back-compat shim: the older entry point ignored touched ids.
2083    fn execute_update_inner(
2084        &self,
2085        raw_query: &str,
2086        query: &UpdateQuery,
2087    ) -> RedDBResult<RuntimeQueryResult> {
2088        self.execute_update_inner_tracked(raw_query, query)
2089            .map(|(res, _)| res)
2090    }
2091
2092    /// Enforce the concurrent-claim ORDER BY index-gate (ADR 0063, #1607).
2093    ///
2094    /// A `CLAIM LIMIT n` / `CLAIM EXACT n` on a logical-identity model (tables
2095    /// and documents) must order its candidates through a compatible index; the
2096    /// planner rejects an ordering no index on the collection can serve rather
2097    /// than falling back to a broad write-path sort. KV (key identity) and graph
2098    /// nodes/edges are exempt — their claim identity is intrinsic, not a user
2099    /// ORDER BY column that needs a secondary index.
2100    fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
2101        if query.claim_limit.is_none()
2102            || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
2103        {
2104            return Ok(());
2105        }
2106        let available_indexes: Vec<Vec<String>> = self
2107            .index_store_ref()
2108            .list_indices(&query.table)
2109            .into_iter()
2110            .map(|index| index.columns)
2111            .collect();
2112        reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
2113            .map_err(RedDBError::InvalidOperation)
2114    }
2115
2116    fn execute_update_inner_tracked(
2117        &self,
2118        raw_query: &str,
2119        query: &UpdateQuery,
2120    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2121        self.enforce_claim_order_by_index_gate(query)?;
2122        let store = self.inner.db.store();
2123        let topology_lock = self
2124            .index_store_ref()
2125            .collection_topology_lock(&query.table);
2126        let effective_filter = effective_update_filter(query);
2127        let compiled_plan = self.compile_update_plan(query)?;
2128        // Autocommit table-row UPDATE replaces an MVCC version even when its
2129        // assignments are constants. Resolve the current pre-image under the
2130        // RMW lock. Explicit transactions keep their statement snapshot and
2131        // first-committer-wins conflict detection.
2132        let autocommit_table_update =
2133            matches!(query.target, UpdateTarget::Rows) && self.current_xid().is_none();
2134        let needs_rmw_lock = autocommit_table_update || update_needs_rmw_lock(query);
2135        let claim_model = update_model_name(update_target_model(query.target));
2136        if query.claim_limit.is_some() {
2137            self.inner
2138                .claim_telemetry
2139                .record_attempt(&query.table, claim_model);
2140        }
2141        let claim_lock = query.claim_limit.map(|_| {
2142            self.inner
2143                .rmw_locks
2144                .lock_for(&query.table, "__table_claim_update__")
2145        });
2146        let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2147            let Some(guard) = lock.try_lock() else {
2148                let skipped_locked = query.claim_limit.unwrap_or(1);
2149                self.inner.claim_telemetry.record_skipped_locked(
2150                    &query.table,
2151                    claim_model,
2152                    skipped_locked,
2153                );
2154                tracing::debug!(
2155                    target: "reddb::claim",
2156                    collection = %query.table,
2157                    model = claim_model,
2158                    skipped_locked,
2159                    "concurrent claim skipped locked candidates"
2160                );
2161                return Ok((
2162                    RuntimeQueryResult::dml_result(
2163                        raw_query.to_string(),
2164                        0,
2165                        "update",
2166                        "runtime-dml",
2167                    ),
2168                    Vec::new(),
2169                ));
2170            };
2171            Some(guard)
2172        } else {
2173            None
2174        };
2175        let table_rmw_lock = if needs_rmw_lock {
2176            Some(
2177                self.inner
2178                    .rmw_locks
2179                    .lock_for(&query.table, "__table_rmw_update__"),
2180            )
2181        } else {
2182            None
2183        };
2184        let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2185        let mut touched_ids: Vec<EntityId> = Vec::new();
2186        let claim_cap = query.claim_limit.map(|limit| limit as usize);
2187        let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2188        let manager = store
2189            .get_collection(&query.table)
2190            .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2191        let scan_limit = if query.order_by.is_empty() {
2192            limit_cap
2193        } else {
2194            None
2195        };
2196        let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2197            self,
2198            &query.table,
2199            effective_filter.as_ref(),
2200            scan_limit,
2201            query.target,
2202        );
2203        if needs_rmw_lock && !autocommit_table_update {
2204            target_scan = target_scan.with_live_table_rows();
2205        }
2206        let ids_to_update = {
2207            let _topology_guard = topology_lock.read();
2208            // The statement snapshot predates the table lock. Refresh it
2209            // after acquiring that lock so earlier autocommits are visible,
2210            // but never select an uncommitted physical tip. Historical
2211            // versions can be absent from current indexes while a writer is
2212            // active, so retain the snapshot-aware index fallback then.
2213            let _fresh_snapshot = if autocommit_table_update {
2214                let mut ctx =
2215                    crate::runtime::impl_core::capture_current_snapshot().unwrap_or_else(|| {
2216                        let manager = self.snapshot_manager();
2217                        crate::runtime::impl_core::SnapshotContext {
2218                            snapshot: manager.fresh_read_snapshot(),
2219                            manager,
2220                            own_xids: std::collections::HashSet::new(),
2221                            requires_index_fallback: false,
2222                            serializable_reader: None,
2223                        }
2224                    });
2225                ctx.snapshot = ctx.manager.fresh_read_snapshot();
2226                ctx.requires_index_fallback |= !ctx.snapshot.in_progress.is_empty();
2227                Some(crate::runtime::impl_core::CurrentSnapshotGuard::install(
2228                    ctx,
2229                ))
2230            } else {
2231                None
2232            };
2233            target_scan.find_target_ids()?
2234        };
2235        let order_limit = if query.claim_limit.is_some() {
2236            None
2237        } else {
2238            limit_cap
2239        };
2240        let ids_to_update = if query.order_by.is_empty() {
2241            ids_to_update
2242        } else {
2243            ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)?
2244        };
2245        let mut ids_to_update = if query.claim_limit.is_some() || autocommit_table_update {
2246            self.filter_claim_locked_target_ids(&query.table, ids_to_update)?
2247        } else {
2248            ids_to_update
2249        };
2250        if let Some(claim_cap) = claim_cap {
2251            ids_to_update.truncate(claim_cap);
2252        }
2253        if query.claim_exact
2254            && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2255        {
2256            self.inner
2257                .claim_telemetry
2258                .record_miss(&query.table, claim_model);
2259            return Ok((
2260                RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2261                Vec::new(),
2262            ));
2263        }
2264
2265        // Cover the interval from post-image preparation through storage and
2266        // secondary-index publication. Target selection above remains indexed.
2267        let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
2268        if needs_rmw_lock {
2269            let result = self.execute_update_inner_tracked_locked(
2270                raw_query,
2271                query,
2272                &compiled_plan,
2273                &ids_to_update,
2274                effective_filter.as_ref(),
2275            )?;
2276            if query.claim_limit.is_some() {
2277                self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2278            }
2279            record_claim_outcome(
2280                &self.inner.claim_telemetry,
2281                query.claim_limit,
2282                &query.table,
2283                claim_model,
2284                result.0.affected_rows,
2285            );
2286            return Ok(result);
2287        }
2288
2289        // Keep topology fixed while reserving every post-image. No chunk is
2290        // published until all targets fit; a later denial cannot leave earlier
2291        // autocommit chunks visible. Prepared payload is bounded by admission.
2292        let topology_guard = topology_lock.read();
2293        let mut prepared = Vec::new();
2294        let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2295        for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2296            for id in chunk {
2297                #[cfg(test)]
2298                self.index_store_ref().mutation_test_hook(
2299                    &query.table,
2300                    super::index_store::MutationTestPhase::DmlPostImageCandidate,
2301                );
2302                super::query_budget::charge(1)?;
2303                let Some(entity) = manager.try_get(*id, usize::MAX)? else {
2304                    continue;
2305                };
2306                let assignments =
2307                    self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2308                let applied = self.apply_materialized_update_for_entity(
2309                    query,
2310                    entity,
2311                    &compiled_plan,
2312                    assignments,
2313                )?;
2314                reservations.admit(&applied)?;
2315                touched_ids.push(applied.id);
2316                prepared.push(applied);
2317            }
2318        }
2319        let affected = prepared.len() as u64;
2320        let mut published = 0;
2321        let mut _durable_publication = None;
2322        let persistence = (|| -> RedDBResult<()> {
2323            super::query_budget::charge(0)?;
2324            // Standalone UNIQUE keys are admitted for the whole statement
2325            // before any chunk is published.
2326            self.enforce_unique_update_post_images(&query.table, &compiled_plan, &prepared)?;
2327            // This is the cancellation boundary. Once durable publication
2328            // starts, finish the prepared statement rather than exposing a
2329            // timeout with only a prefix of its chunks committed.
2330            _durable_publication = Some(super::query_budget::begin_durable_publication()?);
2331            for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2332                if compiled_plan.row_touches_unique_columns
2333                    || compiled_plan
2334                        .row_contract_plan
2335                        .as_ref()
2336                        .is_some_and(|plan| plan.has_expressions)
2337                {
2338                    let db = self.db();
2339                    let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2340                    for item in chunk {
2341                        let fields =
2342                            crate::application::ports::entity_row_fields_snapshot(&item.entity);
2343                        let previous_id =
2344                            item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2345                        contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2346                    }
2347                }
2348                self.persist_update_chunk(chunk)?;
2349                published += chunk.len();
2350            }
2351            Ok(())
2352        })();
2353        // A later storage/constraint error must still finish index and CDC
2354        // maintenance for earlier published chunks, as the chunked path did.
2355        let published_mutations = &prepared[..published];
2356        let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2357        if !query.suppress_events {
2358            self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2359        }
2360        persistence?;
2361        drop(reservations);
2362
2363        if affected > 0 {
2364            self.note_table_write(&query.table);
2365        }
2366        if query.claim_limit.is_some() {
2367            self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2368        }
2369        record_claim_outcome(
2370            &self.inner.claim_telemetry,
2371            query.claim_limit,
2372            &query.table,
2373            claim_model,
2374            affected,
2375        );
2376
2377        Ok((
2378            RuntimeQueryResult::dml_result(
2379                raw_query.to_string(),
2380                affected,
2381                "update",
2382                "runtime-dml",
2383            ),
2384            touched_ids,
2385        ))
2386    }
2387
2388    fn filter_claim_locked_target_ids(
2389        &self,
2390        table: &str,
2391        ids: Vec<EntityId>,
2392    ) -> RedDBResult<Vec<EntityId>> {
2393        let conn_id = current_connection_id();
2394        let locks = self.inner.pending_claim_locks.read();
2395        if locks.is_empty() {
2396            return Ok(ids);
2397        }
2398        let mut admitted = Vec::with_capacity(ids.len());
2399        for id in ids {
2400            super::query_budget::charge(1)?;
2401            if let Some(entity) = self.inner.db.store().try_get(table, id, usize::MAX)? {
2402                let key = (table.to_string(), entity.logical_id());
2403                if locks.get(&key).is_none_or(|owner| *owner == conn_id) {
2404                    admitted.push(id);
2405                }
2406            }
2407        }
2408        Ok(admitted)
2409    }
2410
2411    fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2412        if self.current_xid().is_none() || ids.is_empty() {
2413            return;
2414        }
2415
2416        let conn_id = current_connection_id();
2417        let store = self.inner.db.store();
2418        let mut locks = self.inner.pending_claim_locks.write();
2419        for id in ids {
2420            let Some(entity) = store.get(table, *id) else {
2421                continue;
2422            };
2423            locks.insert((table.to_string(), entity.logical_id()), conn_id);
2424        }
2425    }
2426
2427    fn execute_update_inner_tracked_locked(
2428        &self,
2429        raw_query: &str,
2430        query: &UpdateQuery,
2431        compiled_plan: &CompiledUpdatePlan,
2432        ids_to_update: &[EntityId],
2433        effective_filter: Option<&Filter>,
2434    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2435        // Existing table/claim locks precede topology; per-row RMW locks
2436        // below follow it. Keep the same index set through persistence/flush.
2437        let topology_lock = self
2438            .index_store_ref()
2439            .collection_topology_lock(&query.table);
2440        let topology_guard = topology_lock.read();
2441        let store = self.inner.db.store();
2442        let mut touched_ids = Vec::new();
2443        let mut lock_entries = Vec::new();
2444
2445        for id in ids_to_update {
2446            super::query_budget::charge(1)?;
2447            let Some(candidate) = store.try_get(&query.table, *id, usize::MAX)? else {
2448                continue;
2449            };
2450            let logical_id = candidate.logical_id();
2451            let lock_key = super::RmwLockTable::row_key(logical_id);
2452            let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2453            lock_entries.push((lock_key, logical_id, rmw_lock));
2454        }
2455
2456        lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2457        lock_entries.dedup_by(|left, right| left.0 == right.0);
2458        let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2459
2460        let mut applied_chunk = Vec::new();
2461        let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2462        for (_, logical_id, _) in &lock_entries {
2463            #[cfg(test)]
2464            self.index_store_ref().mutation_test_hook(
2465                &query.table,
2466                super::index_store::MutationTestPhase::DmlPostImageCandidate,
2467            );
2468            super::query_budget::charge(1)?;
2469            let Some(entity) =
2470                try_resolve_update_entity_by_logical_id(self, &query.table, *logical_id)?
2471            else {
2472                continue;
2473            };
2474            if let Some(filter) = effective_filter {
2475                if !crate::runtime::query_exec::evaluate_entity_filter_result_with_db(
2476                    Some(self.inner.db.as_ref()),
2477                    &entity,
2478                    filter,
2479                    &query.table,
2480                    &query.table,
2481                )? {
2482                    continue;
2483                }
2484            }
2485
2486            let assignments =
2487                self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2488            let applied = self.apply_materialized_update_for_entity(
2489                query,
2490                entity,
2491                compiled_plan,
2492                assignments,
2493            )?;
2494            reservations.admit(&applied)?;
2495            touched_ids.push(applied.id);
2496            applied_chunk.push(applied);
2497        }
2498
2499        let affected = applied_chunk.len() as u64;
2500        let mut published = 0;
2501        let mut _durable_publication = None;
2502        let persistence = (|| -> RedDBResult<()> {
2503            super::query_budget::charge(0)?;
2504            self.enforce_unique_update_post_images(&query.table, compiled_plan, &applied_chunk)?;
2505            // Final cooperative checkpoint before the first durable chunk.
2506            // Cancellation after this point cannot safely abort a prefix.
2507            _durable_publication = Some(super::query_budget::begin_durable_publication()?);
2508            for chunk in applied_chunk.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2509                if compiled_plan.row_touches_unique_columns
2510                    || compiled_plan
2511                        .row_contract_plan
2512                        .as_ref()
2513                        .is_some_and(|plan| plan.has_expressions)
2514                {
2515                    let db = self.db();
2516                    let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2517                    for item in chunk {
2518                        let fields =
2519                            crate::application::ports::entity_row_fields_snapshot(&item.entity);
2520                        let previous_id =
2521                            item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2522                        contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2523                    }
2524                }
2525                self.persist_update_chunk(chunk)?;
2526                published += chunk.len();
2527            }
2528            Ok(())
2529        })();
2530        // Earlier chunks may have been published when a later constraint
2531        // check fails. Finish their indexes and events before returning it.
2532        let published_mutations = &applied_chunk[..published];
2533        let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2534        if !query.suppress_events {
2535            self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2536        }
2537        persistence?;
2538
2539        if affected > 0 {
2540            self.note_table_write(&query.table);
2541        }
2542
2543        Ok((
2544            RuntimeQueryResult::dml_result(
2545                raw_query.to_string(),
2546                affected,
2547                "update",
2548                "runtime-dml",
2549            ),
2550            touched_ids,
2551        ))
2552    }
2553
2554    fn enforce_unique_update_post_images(
2555        &self,
2556        collection: &str,
2557        compiled_plan: &CompiledUpdatePlan,
2558        applied: &[AppliedEntityMutation],
2559    ) -> RedDBResult<()> {
2560        if !compiled_plan.touches_unique_index {
2561            return Ok(());
2562        }
2563        let post_images: Vec<_> = applied
2564            .iter()
2565            .map(|item| {
2566                (
2567                    item.replaced_entity.as_ref().map_or(item.id, |old| old.id),
2568                    crate::application::ports::entity_row_fields_snapshot(&item.entity),
2569                )
2570            })
2571            .collect();
2572        self.enforce_unique_updates(collection, &post_images)
2573    }
2574
2575    fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2576        let mut static_field_assignments = Vec::new();
2577        let mut static_metadata_assignments = Vec::new();
2578        let mut dynamic_assignments = Vec::new();
2579        let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2580        let mut row_modified_columns = Vec::new();
2581
2582        for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2583            let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2584            let metadata_key = resolve_sql_ttl_metadata_key(column);
2585            if compound_op.is_some() && metadata_key.is_some() {
2586                return Err(RedDBError::Query(format!(
2587                    "compound assignment is only supported for row fields: {column}"
2588                )));
2589            }
2590            if compound_op.is_none() {
2591                if let Ok(value) = fold_expr_to_value(expr.clone()) {
2592                    // Without a declared type, a text constant may overwrite an
2593                    // existing Secret. Resolve it per row to preserve encryption.
2594                    let unknown_text_target = metadata_key.is_none()
2595                        && matches!(value, Value::Text(_))
2596                        && row_contract_plan
2597                            .as_ref()
2598                            .and_then(|plan| plan.declared_rules.get(column))
2599                            .is_none();
2600                    if !unknown_text_target {
2601                        if let Some(metadata_key) = metadata_key {
2602                            let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2603                            let (canonical_key, canonical_value) =
2604                                canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2605                            static_metadata_assignments
2606                                .push((canonical_key.to_string(), canonical_value));
2607                        } else {
2608                            let is_secret_column = row_contract_plan
2609                                .as_ref()
2610                                .and_then(|plan| plan.declared_rules.get(column))
2611                                .is_some_and(|rule| {
2612                                    rule.data_type == reddb_types::DataType::Secret
2613                                });
2614                            let value =
2615                                self.resolve_secret_column_value(value, is_secret_column)?;
2616                            static_field_assignments.push((
2617                                column.clone(),
2618                                normalize_row_update_assignment_with_plan(
2619                                    &query.table,
2620                                    column,
2621                                    value,
2622                                    row_contract_plan.as_ref(),
2623                                )?,
2624                            ));
2625                            row_modified_columns.push(column.clone());
2626                        }
2627                        continue;
2628                    }
2629                }
2630            }
2631
2632            dynamic_assignments.push(CompiledUpdateAssignment {
2633                column: column.clone(),
2634                expr: expr.clone(),
2635                compound_op,
2636                metadata_key,
2637                row_rule: if metadata_key.is_none() {
2638                    if let Some(plan) = row_contract_plan.as_ref() {
2639                        if plan.timestamps_enabled
2640                            && (column == "created_at" || column == "updated_at")
2641                        {
2642                            return Err(RedDBError::Query(format!(
2643                                "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2644                                query.table, column
2645                            )));
2646                        }
2647                        if let Some(rule) = plan.declared_rules.get(column) {
2648                            Some(rule.clone())
2649                        } else if plan.strict_schema {
2650                            return Err(RedDBError::Query(format!(
2651                                "collection '{}' is strict and does not allow undeclared fields: {}",
2652                                query.table, column
2653                            )));
2654                        } else {
2655                            None
2656                        }
2657                    } else {
2658                        None
2659                    }
2660                } else {
2661                    None
2662                },
2663            });
2664            if metadata_key.is_none() {
2665                row_modified_columns.push(column.clone());
2666            }
2667        }
2668
2669        let row_modified_columns = dedupe_update_columns(row_modified_columns);
2670        let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2671            row_modified_columns.iter().any(|column| {
2672                plan.unique_columns
2673                    .keys()
2674                    .any(|unique| unique.eq_ignore_ascii_case(column))
2675            })
2676        });
2677
2678        let touches_unique_index = self
2679            .index_store_ref()
2680            .touches_unique_index(&query.table, &row_modified_columns);
2681
2682        if let Some(ttl_ms) = query.ttl_ms {
2683            static_metadata_assignments
2684                .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2685        }
2686        if let Some(expires_at_ms) = query.expires_at_ms {
2687            static_metadata_assignments.push((
2688                "_expires_at".to_string(),
2689                metadata_u64_to_value(expires_at_ms),
2690            ));
2691        }
2692        for (key, val) in &query.with_metadata {
2693            static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2694        }
2695
2696        Ok(CompiledUpdatePlan {
2697            static_field_assignments,
2698            static_metadata_assignments,
2699            dynamic_assignments,
2700            row_contract_plan,
2701            row_modified_columns,
2702            row_touches_unique_columns,
2703            touches_unique_index,
2704        })
2705    }
2706
2707    fn materialize_update_assignments_for_entity(
2708        &self,
2709        query: &UpdateQuery,
2710        entity: &UnifiedEntity,
2711        compiled_plan: &CompiledUpdatePlan,
2712    ) -> RedDBResult<MaterializedUpdateAssignments> {
2713        let mut assignments = MaterializedUpdateAssignments::default();
2714        let mut record: Option<UnifiedRecord> = None;
2715
2716        for assignment in &compiled_plan.dynamic_assignments {
2717            if assignment.compound_op.is_some()
2718                && !matches!(
2719                    entity.data,
2720                    EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2721                )
2722            {
2723                return Err(RedDBError::Query(format!(
2724                    "compound assignment is only supported for row or graph UPDATE column '{}'",
2725                    assignment.column
2726                )));
2727            }
2728            if record.is_none() {
2729                record = runtime_any_record_from_entity_ref(entity);
2730            }
2731            let Some(record) = record.as_ref() else {
2732                return Err(RedDBError::Query(format!(
2733                    "UPDATE could not materialize runtime record for entity {} in '{}'",
2734                    entity.id.raw(),
2735                    query.table
2736                )));
2737            };
2738            let rhs = super::expr_eval::try_evaluate_runtime_expr_with_db(
2739                Some(self.inner.db.as_ref()),
2740                &assignment.expr,
2741                record,
2742                Some(query.table.as_str()),
2743                Some(query.table.as_str()),
2744            )?
2745            .ok_or_else(|| {
2746                RedDBError::Query(format!(
2747                    "failed to evaluate UPDATE expression for column '{}'",
2748                    assignment.column
2749                ))
2750            })?;
2751            let value = if let Some(op) = assignment.compound_op {
2752                evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2753            } else {
2754                rhs
2755            };
2756
2757            let sensitive = super::join_filter::expression_uses_secret(
2758                &assignment.expr,
2759                record,
2760                Some(&query.table),
2761                Some(&query.table),
2762            );
2763            let secret_target = assignment
2764                .row_rule
2765                .as_ref()
2766                .is_some_and(|rule| rule.data_type == reddb_types::DataType::Secret)
2767                || record
2768                    .get(&assignment.column)
2769                    .is_some_and(|value| matches!(value, Value::Secret(_)));
2770            if sensitive && !secret_target {
2771                return Err(RedDBError::Query(
2772                    "secret-derived UPDATE values require a SECRET target column".into(),
2773                ));
2774            }
2775            if let Some(metadata_key) = assignment.metadata_key {
2776                let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2777                let (canonical_key, canonical_value) =
2778                    canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2779                assignments
2780                    .dynamic_metadata_assignments
2781                    .push((canonical_key.to_string(), canonical_value));
2782            } else {
2783                assignments.dynamic_field_assignments.push((
2784                    assignment.column.clone(),
2785                    normalize_row_update_value_for_rule(
2786                        &query.table,
2787                        self.resolve_secret_column_value(
2788                            value,
2789                            assignment.row_rule.as_ref().is_some_and(|rule| {
2790                                rule.data_type == reddb_types::DataType::Secret
2791                            }) || record
2792                                .get(&assignment.column)
2793                                .is_some_and(|value| matches!(value, Value::Secret(_))),
2794                        )?,
2795                        assignment.row_rule.as_ref(),
2796                    )?,
2797                ));
2798            }
2799        }
2800
2801        Ok(assignments)
2802    }
2803
2804    fn apply_materialized_update_for_entity(
2805        &self,
2806        query: &UpdateQuery,
2807        entity: UnifiedEntity,
2808        compiled_plan: &CompiledUpdatePlan,
2809        mut assignments: MaterializedUpdateAssignments,
2810    ) -> RedDBResult<AppliedEntityMutation> {
2811        if matches!(query.target, UpdateTarget::Kv)
2812            && !compiled_plan
2813                .static_field_assignments
2814                .iter()
2815                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2816            && !assignments
2817                .dynamic_field_assignments
2818                .iter()
2819                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2820        {
2821            if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2822                .and_then(|record| record.get("key").cloned())
2823            {
2824                assignments
2825                    .dynamic_field_assignments
2826                    .push(("key".to_string(), key));
2827            }
2828        }
2829        if matches!(entity.data, EntityData::Row(_)) {
2830            return self.apply_loaded_sql_update_row_core(
2831                query.table.clone(),
2832                entity,
2833                &compiled_plan.static_field_assignments,
2834                assignments.dynamic_field_assignments,
2835                &compiled_plan.static_metadata_assignments,
2836                assignments.dynamic_metadata_assignments,
2837                compiled_plan.row_contract_plan.as_ref(),
2838                &compiled_plan.row_modified_columns,
2839                compiled_plan.row_touches_unique_columns,
2840            );
2841        }
2842
2843        ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2844
2845        let operations = build_patch_operations_from_materialized_assignments(
2846            &entity,
2847            compiled_plan,
2848            assignments,
2849        );
2850        self.apply_loaded_patch_entity_core(
2851            query.table.clone(),
2852            entity,
2853            crate::json::Value::Null,
2854            operations,
2855        )
2856    }
2857
2858    /// Execute DELETE FROM table WHERE filter
2859    pub fn execute_delete(
2860        &self,
2861        raw_query: &str,
2862        query: &DeleteQuery,
2863    ) -> RedDBResult<RuntimeQueryResult> {
2864        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2865        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2866            self,
2867            &query.table,
2868        );
2869        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2870        // Issue #523 — blockchain collections are immutable; see
2871        // execute_update for the same gate.
2872        if crate::runtime::blockchain_kind::try_is_chain(
2873            self.inner.db.store().as_ref(),
2874            &query.table,
2875        )? {
2876            return Err(RedDBError::InvalidOperation(format!(
2877                "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2878                query.table
2879            )));
2880        }
2881        // CollectionContract gate (#50) — see execute_update for
2882        // rationale. The gate handles APPEND ONLY rejection and is
2883        // the single point where future contract bits land.
2884        crate::runtime::collection_contract::CollectionContractGate::check(
2885            self,
2886            &query.table,
2887            crate::runtime::collection_contract::MutationKind::Delete,
2888        )?;
2889
2890        // RETURNING on DELETE: capture the pre-image via an internal
2891        // SELECT that reuses the same WHERE, then run the delete with
2892        // the RETURNING clause stripped, then project the captured
2893        // rows through the requested items. The extra SELECT is a
2894        // pragmatic MVP — a future pass can fuse the scan with the
2895        // delete to avoid the second pass over the heap.
2896        if let Some(items) = query.returning.clone() {
2897            let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2898                RedDBError::Query(
2899                    "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2900                )
2901            })?;
2902            let captured = self.execute_query(&select_sql)?;
2903
2904            let mut inner_query = query.clone();
2905            inner_query.returning = None;
2906            let _ = self.execute_delete(raw_query, &inner_query)?;
2907
2908            let snapshots: Vec<Vec<(String, Value)>> = captured
2909                .result
2910                .records
2911                .iter()
2912                .map(|rec| {
2913                    rec.iter_fields()
2914                        .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2915                        .collect()
2916                })
2917                .collect();
2918            let affected = snapshots.len() as u64;
2919            let result = build_returning_result(&items, &snapshots, None);
2920
2921            let mut response = RuntimeQueryResult::dml_result(
2922                raw_query.to_string(),
2923                affected,
2924                "delete",
2925                "runtime-dml-returning",
2926            );
2927            response.result = result;
2928            return Ok(response);
2929        }
2930        // Row-Level Security enforcement (Phase 2.5.2 PG parity).
2931        //
2932        // When the table has RLS enabled, gate the DELETE by the
2933        // per-role policy set: mutations only touch rows that *every*
2934        // matching `FOR DELETE` policy would accept. No policies =>
2935        // zero rows affected (PG restrictive-default).
2936        if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2937            let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2938                self,
2939                &query.table,
2940                reddb_rql::ast::PolicyAction::Delete,
2941            );
2942            let Some(policy) = rls_filter else {
2943                return Ok(RuntimeQueryResult::dml_result(
2944                    raw_query.to_string(),
2945                    0,
2946                    "delete",
2947                    "runtime-dml-rls",
2948                ));
2949            };
2950            // Fold the policy predicate into the user's WHERE before
2951            // dispatching — the remainder of this function reads the
2952            // filter from `query` via `effective_delete_filter`, which
2953            // respects the updated value.
2954            let mut augmented = query.clone();
2955            augmented.filter = Some(match augmented.filter.take() {
2956                Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2957                None => policy,
2958            });
2959            return self.execute_delete_inner(raw_query, &augmented);
2960        }
2961        self.execute_delete_inner(raw_query, query)
2962    }
2963
2964    fn execute_delete_inner(
2965        &self,
2966        raw_query: &str,
2967        query: &DeleteQuery,
2968    ) -> RedDBResult<RuntimeQueryResult> {
2969        let maintenance_limit = super::retention_sweeper::take_delete_limit();
2970        let topology_lock = self
2971            .index_store_ref()
2972            .collection_topology_lock(&query.table);
2973        let effective_filter = effective_delete_filter(query);
2974
2975        // Find the rows that match the WHERE clause. The "find target
2976        // rows" loop lives in DmlTargetScan so UPDATE (#52) can reuse
2977        // the same scan strategy.
2978        let scan = super::dml_target_scan::DmlTargetScan::new(
2979            self,
2980            &query.table,
2981            effective_filter.as_ref(),
2982            maintenance_limit,
2983        );
2984        let ids_to_delete = {
2985            let _topology_guard = topology_lock.read();
2986            let ids = scan.find_target_ids()?;
2987            if ids.len() > UPDATE_APPLY_CHUNK_SIZE
2988                && self.current_xid().is_none()
2989                && UnifiedStore::deferred_store_wal_capture_active()
2990            {
2991                super::delete_publication::order_target_ids(self, &query.table, ids)?
2992            } else {
2993                ids
2994            }
2995        };
2996
2997        let needs_delete_events =
2998            !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2999        let mut affected: u64 = 0;
3000        // Final cooperative checkpoint before DELETE begins publishing its
3001        // prepared target set. A later timeout must not strand a partial
3002        // statement merely to stop sooner.
3003        let _durable_publication = super::query_budget::begin_durable_publication()?;
3004        for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
3005            let deleted = self.delete_entities_batch(
3006                &query.table,
3007                chunk,
3008                topology_lock.read(),
3009                effective_filter.as_ref(),
3010                needs_delete_events,
3011            )?;
3012            #[cfg(test)]
3013            self.index_store_ref().mutation_test_hook(
3014                &query.table,
3015                super::index_store::MutationTestPhase::BeforeEvents,
3016            );
3017            affected += deleted.affected;
3018            if needs_delete_events && !deleted.changes.is_empty() {
3019                let ids: Vec<_> = deleted.changes.iter().map(|(id, _)| *id).collect();
3020                let lsns: Vec<_> = deleted.changes.iter().map(|(_, lsn)| *lsn).collect();
3021                self.emit_delete_events_for_collection(
3022                    &query.table,
3023                    &ids,
3024                    &lsns,
3025                    &deleted.pre_images,
3026                )?;
3027            }
3028        }
3029
3030        if affected > 0 {
3031            self.note_table_write(&query.table);
3032        }
3033
3034        Ok(RuntimeQueryResult::dml_result(
3035            raw_query.to_string(),
3036            affected,
3037            "delete",
3038            "runtime-dml",
3039        ))
3040    }
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045    use crate::storage::wal::{WalReader, WalRecord};
3046    use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
3047    use crate::{RedDBOptions, RedDBRuntime};
3048    use reddb_types::Value;
3049    use std::path::Path;
3050
3051    #[test]
3052    fn captured_bulk_delete_keeps_global_row_lock_order_across_chunks() {
3053        use std::sync::{
3054            atomic::{AtomicUsize, Ordering},
3055            Arc,
3056        };
3057        let runtime = Arc::new(
3058            RedDBRuntime::with_options(
3059                RedDBOptions::in_memory().with_memory_budget(128 * 1024 * 1024),
3060            )
3061            .expect("bulk lock fixture"),
3062        );
3063        runtime
3064            .execute_query("CREATE TABLE bulk_lock_order (id INTEGER)")
3065            .expect("table");
3066        let count = super::UPDATE_APPLY_CHUNK_SIZE + 16;
3067        for batch in (0..count).collect::<Vec<_>>().chunks(512) {
3068            let values = batch
3069                .iter()
3070                .map(|id| format!("({id})"))
3071                .collect::<Vec<_>>()
3072                .join(",");
3073            runtime
3074                .execute_query(&format!("INSERT INTO bulk_lock_order (id) VALUES {values}"))
3075                .expect("seed chunk boundary");
3076        }
3077        let manager = runtime
3078            .db()
3079            .store()
3080            .get_collection("bulk_lock_order")
3081            .expect("rows");
3082        // Imported/versioned rows can have logical identities independent
3083        // of their physical positions. Cross decimal-width boundaries too;
3084        // fresh time-based physical IDs alone keep the same string order.
3085        let mut rows = manager.query_all(|_| true);
3086        rows.sort_unstable_by_key(|entity| entity.id);
3087        for (position, mut entity) in rows.into_iter().enumerate() {
3088            entity.set_logical_id(crate::storage::EntityId::new(
3089                u64::try_from(position + 1).expect("small identity"),
3090            ));
3091            manager
3092                .update(entity)
3093                .expect("independent logical identity");
3094        }
3095        let mut expected: Vec<_> = manager
3096            .query_all(|_| true)
3097            .iter()
3098            .map(|entity| format!("row:{}", entity.logical_id().raw()))
3099            .collect();
3100        expected.sort_unstable();
3101        let calls = Arc::new(AtomicUsize::new(0));
3102        let hook_calls = Arc::clone(&calls);
3103        let weak_runtime = Arc::downgrade(&runtime);
3104        *runtime.index_store_ref().mutation_hook.lock() =
3105            Some(Arc::new(move |collection, phase| {
3106                if collection == "bulk_lock_order"
3107                    && phase == super::super::index_store::MutationTestPhase::BeforeEvents
3108                {
3109                    let runtime = weak_runtime.upgrade().expect("writer still alive");
3110                    let held =
3111                        super::super::delete_publication::pending_lock_keys(&runtime, collection);
3112                    assert!(!held.is_empty());
3113                    assert!(held.len() <= expected.len());
3114                    let mismatch = held
3115                        .iter()
3116                        .zip(&expected)
3117                        .position(|(actual, expected)| actual != expected);
3118                    assert!(
3119                        mismatch.is_none(),
3120                        "captured DELETE violated global lock order at position {mismatch:?}"
3121                    );
3122                    hook_calls.fetch_add(1, Ordering::SeqCst);
3123                }
3124            }));
3125        let result = runtime
3126            .with_deferred_store_wal_for_dml(true, || {
3127                runtime.execute_query("DELETE FROM bulk_lock_order")
3128            })
3129            .expect("captured bulk delete");
3130        assert_eq!(
3131            result.affected_rows,
3132            u64::try_from(count).expect("small fixture")
3133        );
3134        assert!(
3135            calls.load(Ordering::SeqCst) >= 2,
3136            "fixture must cross an apply chunk"
3137        );
3138        assert!(runtime
3139            .execute_query("SELECT id FROM bulk_lock_order")
3140            .expect("deleted rows")
3141            .result
3142            .records
3143            .is_empty());
3144        assert_eq!(runtime.snapshot_manager().oldest_active_xid(), None);
3145    }
3146
3147    #[test]
3148    fn locked_update_recheck_read_failure_preserves_preimages_and_retry() {
3149        use crate::storage::engine::BTree;
3150        use reddb_engine::storage::collection_read::PagedCollection;
3151        use std::sync::Arc;
3152
3153        let directory = tempfile::tempdir().expect("locked update fixture");
3154        let options = RedDBOptions::persistent(directory.path().join("locked-update.rdb"))
3155            .with_memory_budget(128 * 1024 * 1024)
3156            .with_storage_profile(
3157                crate::storage::profile::StorageDeployPreset::PrimaryReplicaDev.selection(),
3158            )
3159            .expect("locked update profile");
3160        let runtime = RedDBRuntime::with_options(options).expect("locked update runtime");
3161        for sql in [
3162            "CREATE TABLE recheck_probe (id INTEGER)",
3163            "INSERT INTO recheck_probe (id) VALUES (1), (2)",
3164            "SET CONFIG fixture.flag = 'ready'",
3165        ] {
3166            runtime.execute_query(sql).expect(sql);
3167        }
3168        let sql = "UPDATE recheck_probe SET id = id + 10 WHERE id = 1 OR CONFIG('fixture.flag') = 'ready'";
3169        let reddb_rql::ast::QueryExpr::Update(query) =
3170            reddb_rql::parse(sql).expect("parse update").query
3171        else {
3172            panic!("expected UPDATE");
3173        };
3174        let compiled = runtime.compile_update_plan(&query).expect("compile update");
3175        let filter = reddb_rql::sql_lowering::effective_update_filter(&query);
3176        let ids = super::super::dml_target_scan::DmlTargetScan::new(
3177            &runtime,
3178            "recheck_probe",
3179            filter.as_ref(),
3180            None,
3181        )
3182        .find_target_ids()
3183        .expect("discover targets before failure");
3184        assert_eq!(ids.len(), 2);
3185        let store = runtime.db().store();
3186        store.persist().expect("publish recheck configuration");
3187        let root = store
3188            .collection_root_page("red_config")
3189            .expect("configuration root");
3190        let manager = store
3191            .get_collection("red_config")
3192            .expect("configuration collection");
3193        let install = |limit| {
3194            let tree = BTree::with_root(Arc::clone(store.pager().expect("pager")), root);
3195            manager.install_backing(
3196                Arc::new(PagedCollection::new(Arc::new(tree))),
3197                root,
3198                store.format_version(),
3199                limit,
3200                manager.count() as u64,
3201            );
3202        };
3203        let assert_ids = |expected: [i64; 2]| {
3204            runtime.invalidate_result_cache();
3205            let rows = runtime
3206                .execute_query("SELECT id FROM recheck_probe ORDER BY id")
3207                .expect("read preimages");
3208            let actual: Vec<_> = rows
3209                .result
3210                .records
3211                .iter()
3212                .map(|row| row.get("id").cloned().expect("row value"))
3213                .collect();
3214            assert_eq!(actual, expected.map(Value::Integer));
3215        };
3216        // Candidate discovery has succeeded. Fail only the locked recheck,
3217        // after the first row's postimage can already have been prepared.
3218        install(1);
3219        let error = runtime
3220            .execute_update_inner_tracked_locked(sql, &query, &compiled, &ids, filter.as_ref())
3221            .err()
3222            .expect("locked recheck must propagate its read failure");
3223        assert!(error.to_string().contains("read memory limit"), "{error}");
3224        install(4096);
3225        assert_ids([1, 2]);
3226        let retried = runtime
3227            .execute_update_inner_tracked_locked(sql, &query, &compiled, &ids, filter.as_ref())
3228            .expect("retry locked recheck");
3229        assert_eq!(retried.0.affected_rows, 2);
3230        assert_ids([11, 12]);
3231    }
3232
3233    #[test]
3234    fn concurrent_constant_updates_leave_one_visible_row_per_logical_id() {
3235        use std::sync::{Arc, Barrier};
3236
3237        const WRITERS: usize = 3;
3238        const ROUNDS: usize = 8;
3239        let rt = Arc::new(RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap());
3240        rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3241            .unwrap();
3242        rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3243            .unwrap();
3244        let barrier = Arc::new(Barrier::new(WRITERS));
3245        let handles: Vec<_> = (0..WRITERS)
3246            .map(|writer| {
3247                let rt = Arc::clone(&rt);
3248                let barrier = Arc::clone(&barrier);
3249                std::thread::spawn(move || {
3250                    for round in 0..ROUNDS {
3251                        barrier.wait();
3252                        rt.execute_query(&format!(
3253                            "UPDATE capabilities SET observed_at = '{writer}-{round}' WHERE id = 'A'"
3254                        ))
3255                        .unwrap();
3256                    }
3257                })
3258            })
3259            .collect();
3260        for handle in handles {
3261            handle.join().unwrap();
3262        }
3263        let selected = rt
3264            .execute_query("SELECT id, observed_at FROM capabilities WHERE id = 'A'")
3265            .unwrap();
3266        assert_eq!(
3267            selected.result.records.len(),
3268            1,
3269            "concurrent updates published multiple visible versions"
3270        );
3271    }
3272
3273    #[test]
3274    fn locked_constant_updates_preserve_unique_constraints() {
3275        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3276        rt.execute_query("CREATE TABLE accounts (id INT PRIMARY KEY, email TEXT UNIQUE)")
3277            .unwrap();
3278        rt.execute_query("INSERT INTO accounts (id, email) VALUES (1, 'a'), (2, 'b')")
3279            .unwrap();
3280        assert!(rt
3281            .execute_query("UPDATE accounts SET email = 'b' WHERE id = 1")
3282            .is_err());
3283        let selected = rt
3284            .execute_query("SELECT id FROM accounts WHERE email = 'a'")
3285            .unwrap();
3286        assert_eq!(selected.result.records.len(), 1);
3287    }
3288
3289    #[test]
3290    fn concurrent_transactions_do_not_both_commit_the_same_row_update() {
3291        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3292
3293        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3294        rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3295            .unwrap();
3296        rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3297            .unwrap();
3298        for connection in [41, 42] {
3299            set_current_connection_id(connection);
3300            rt.execute_query("BEGIN").unwrap();
3301        }
3302        set_current_connection_id(41);
3303        rt.execute_query("UPDATE capabilities SET observed_at = 'first' WHERE id = 'A'")
3304            .unwrap();
3305        set_current_connection_id(42);
3306        let competing =
3307            rt.execute_query("UPDATE capabilities SET observed_at = 'second' WHERE id = 'A'");
3308        set_current_connection_id(41);
3309        rt.execute_query("COMMIT").unwrap();
3310        set_current_connection_id(42);
3311        if competing.is_ok() {
3312            assert!(
3313                rt.execute_query("COMMIT").is_err(),
3314                "both writers committed"
3315            );
3316        } else {
3317            rt.execute_query("ROLLBACK").unwrap();
3318        }
3319        clear_current_connection_id();
3320        let selected = rt
3321            .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3322            .unwrap();
3323        assert_eq!(selected.result.records.len(), 1);
3324    }
3325
3326    #[test]
3327    fn transaction_and_autocommit_update_do_not_publish_two_live_versions() {
3328        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3329
3330        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3331        rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3332            .unwrap();
3333        rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'initial')")
3334            .unwrap();
3335        set_current_connection_id(41);
3336        rt.execute_query("BEGIN").unwrap();
3337        rt.execute_query("UPDATE capabilities SET observed_at = 'transaction' WHERE id = 'A'")
3338            .unwrap();
3339        clear_current_connection_id();
3340        let autocommit =
3341            rt.execute_query("UPDATE capabilities SET observed_at = 'auto' WHERE id = 'A'");
3342        set_current_connection_id(41);
3343        let commit = rt.execute_query("COMMIT");
3344        clear_current_connection_id();
3345        assert!(
3346            autocommit.is_err() || commit.is_err(),
3347            "conflicting writers both committed"
3348        );
3349        let selected = rt
3350            .execute_query("SELECT id FROM capabilities WHERE id = 'A'")
3351            .unwrap();
3352        assert_eq!(selected.result.records.len(), 1);
3353    }
3354
3355    #[test]
3356    fn transaction_update_does_not_target_rows_created_after_snapshot() {
3357        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3358
3359        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3360        rt.execute_query("CREATE TABLE capabilities (id TEXT, observed_at TEXT)")
3361            .unwrap();
3362        set_current_connection_id(41);
3363        rt.execute_query("BEGIN").unwrap();
3364        rt.execute_query("SELECT id FROM capabilities").unwrap();
3365        clear_current_connection_id();
3366        rt.execute_query("INSERT INTO capabilities (id, observed_at) VALUES ('A', 'after')")
3367            .unwrap();
3368        set_current_connection_id(41);
3369        let updated = rt
3370            .execute_query("UPDATE capabilities SET observed_at = 'changed' WHERE id = 'A'")
3371            .unwrap();
3372        assert_eq!(
3373            updated.affected_rows, 0,
3374            "updated a row outside the transaction snapshot"
3375        );
3376        rt.execute_query("COMMIT").unwrap();
3377        clear_current_connection_id();
3378        let selected = rt
3379            .execute_query("SELECT observed_at FROM capabilities WHERE id = 'A'")
3380            .unwrap();
3381        assert_eq!(selected.result.records.len(), 1);
3382        assert_eq!(
3383            selected.result.records[0].get("observed_at"),
3384            Some(&Value::text("after"))
3385        );
3386    }
3387
3388    #[test]
3389    fn autocommit_update_does_not_filter_on_uncommitted_row_image() {
3390        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3391
3392        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3393        rt.execute_query("CREATE TABLE capabilities (id TEXT, status TEXT, payload TEXT)")
3394            .unwrap();
3395        rt.execute_query(
3396            "INSERT INTO capabilities (id, status, payload) VALUES ('A', 'ready', 'old')",
3397        )
3398        .unwrap();
3399        set_current_connection_id(41);
3400        rt.execute_query("BEGIN").unwrap();
3401        rt.execute_query("UPDATE capabilities SET status = 'busy' WHERE id = 'A'")
3402            .unwrap();
3403        clear_current_connection_id();
3404        let autocommit =
3405            rt.execute_query("UPDATE capabilities SET payload = 'new' WHERE status = 'ready'");
3406        if let Ok(result) = &autocommit {
3407            assert_eq!(
3408                result.affected_rows, 1,
3409                "uncommitted status hid the committed target"
3410            );
3411        }
3412        set_current_connection_id(41);
3413        rt.execute_query("ROLLBACK").unwrap();
3414        clear_current_connection_id();
3415        let selected = rt
3416            .execute_query("SELECT status, payload FROM capabilities WHERE id = 'A'")
3417            .unwrap();
3418        assert_eq!(selected.result.records.len(), 1);
3419        assert_eq!(
3420            selected.result.records[0].get("status"),
3421            Some(&Value::text("ready"))
3422        );
3423        if autocommit.is_ok() {
3424            assert_eq!(
3425                selected.result.records[0].get("payload"),
3426                Some(&Value::text("new"))
3427            );
3428        }
3429    }
3430
3431    #[test]
3432    fn autocommit_update_skips_row_claimed_by_another_transaction() {
3433        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
3434
3435        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3436        rt.execute_query("CREATE TABLE capabilities (id INT, rank INT, status TEXT, note TEXT)")
3437            .unwrap();
3438        rt.execute_query("CREATE INDEX capabilities_rank ON capabilities (rank)")
3439            .unwrap();
3440        rt.execute_query(
3441            "INSERT INTO capabilities (id, rank, status, note) VALUES (1, 10, 'ready', 'old')",
3442        )
3443        .unwrap();
3444        set_current_connection_id(41);
3445        rt.execute_query("BEGIN").unwrap();
3446        let claim = rt.execute_query("UPDATE capabilities SET status = 'claimed' WHERE status = 'ready' CLAIM LIMIT 1 ORDER BY rank ASC").unwrap();
3447        assert_eq!(claim.affected_rows, 1);
3448        set_current_connection_id(42);
3449        let other = rt
3450            .execute_query("UPDATE capabilities SET note = 'new' WHERE id = 1")
3451            .unwrap();
3452        assert_eq!(other.affected_rows, 0);
3453        set_current_connection_id(41);
3454        rt.execute_query("ROLLBACK").unwrap();
3455        clear_current_connection_id();
3456        let rows = rt
3457            .execute_query("SELECT status, note FROM capabilities WHERE id = 1")
3458            .unwrap();
3459        assert_eq!(rows.result.records.len(), 1);
3460        assert_eq!(
3461            rows.result.records[0].get("status"),
3462            Some(&Value::text("ready"))
3463        );
3464        assert_eq!(
3465            rows.result.records[0].get("note"),
3466            Some(&Value::text("old"))
3467        );
3468    }
3469
3470    fn persistent_operational_options(path: &Path) -> RedDBOptions {
3471        RedDBOptions::persistent(path)
3472            .with_storage_profile(StorageProfileSelection {
3473                deploy_profile: DeployProfile::Embedded,
3474                packaging: StoragePackaging::OperationalDirectory,
3475                replica_count: 0,
3476                managed_backup: false,
3477                wal_retention: false,
3478            })
3479            .unwrap()
3480    }
3481
3482    fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
3483        WalReader::open(wal_path)
3484            .expect("wal opens")
3485            .iter()
3486            .map(|record| record.expect("wal record decodes").1)
3487            .filter_map(|record| match record {
3488                WalRecord::TxCommitBatch { actions, .. } => Some(actions),
3489                _ => None,
3490            })
3491            .collect()
3492    }
3493
3494    fn action_contains_text(action: &[u8], needle: &str) -> bool {
3495        action
3496            .windows(needle.len())
3497            .any(|window| window == needle.as_bytes())
3498    }
3499
3500    fn claim_metric_count(
3501        snapshot: &crate::runtime::ClaimTelemetrySnapshot,
3502        metric: &str,
3503        collection: &str,
3504        model: &str,
3505    ) -> u64 {
3506        let rows = match metric {
3507            "attempts" => &snapshot.attempts,
3508            "successful" => &snapshot.successful,
3509            "misses" => &snapshot.misses,
3510            "skipped_locked" => &snapshot.skipped_locked,
3511            other => panic!("unknown claim metric {other}"),
3512        };
3513        rows.iter()
3514            .find(|((actual_collection, actual_model), _)| {
3515                actual_collection == collection && actual_model == model
3516            })
3517            .map(|(_, count)| *count)
3518            .unwrap_or(0)
3519    }
3520
3521    fn assert_statement_writes_collections_in_one_new_wal_batch(
3522        rt: &RedDBRuntime,
3523        wal_path: &Path,
3524        statement: &str,
3525        source: &str,
3526        event_queue: &str,
3527    ) {
3528        let before_batches = store_commit_batches(wal_path).len();
3529
3530        rt.execute_query(statement).unwrap();
3531
3532        let batches = store_commit_batches(wal_path);
3533        let statement_batches = &batches[before_batches..];
3534        let source_batch = statement_batches
3535            .iter()
3536            .position(|actions| {
3537                actions.iter().any(|action| {
3538                    action_contains_text(action, source)
3539                        && !action_contains_text(action, event_queue)
3540                })
3541            })
3542            .expect("source collection write batch is present");
3543        let event_batch = statement_batches
3544            .iter()
3545            .position(|actions| {
3546                actions
3547                    .iter()
3548                    .any(|action| action_contains_text(action, event_queue))
3549            })
3550            .expect("event queue write batch is present");
3551
3552        assert_eq!(
3553            source_batch, event_batch,
3554            "WITH EVENTS must persist the source write and queue event in the same WAL batch"
3555        );
3556    }
3557
3558    #[test]
3559    fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
3560        let dir = tempfile::tempdir().unwrap();
3561        let db_path = dir.path().join("events_dual_write.rdb");
3562        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3563        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3564
3565        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3566            .unwrap();
3567        assert_statement_writes_collections_in_one_new_wal_batch(
3568            &rt,
3569            &wal_path,
3570            "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
3571            "users",
3572            "users_events",
3573        );
3574    }
3575
3576    #[test]
3577    fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
3578        let dir = tempfile::tempdir().unwrap();
3579        let db_path = dir.path().join("events_update_atomic.rdb");
3580        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3581        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3582
3583        rt.execute_query(
3584            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
3585        )
3586        .unwrap();
3587        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3588            .unwrap();
3589
3590        assert_statement_writes_collections_in_one_new_wal_batch(
3591            &rt,
3592            &wal_path,
3593            "UPDATE users SET email = 'b@example.test' WHERE id = 1",
3594            "users",
3595            "user_updates",
3596        );
3597    }
3598
3599    #[test]
3600    fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
3601        let dir = tempfile::tempdir().unwrap();
3602        let db_path = dir.path().join("events_delete_atomic.rdb");
3603        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
3604        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
3605
3606        rt.execute_query(
3607            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
3608        )
3609        .unwrap();
3610        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
3611            .unwrap();
3612
3613        assert_statement_writes_collections_in_one_new_wal_batch(
3614            &rt,
3615            &wal_path,
3616            "DELETE FROM users WHERE id = 1",
3617            "users",
3618            "user_deletes",
3619        );
3620    }
3621
3622    #[test]
3623    fn update_where_id_in_with_hash_index_updates_expected_rows() {
3624        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3625        rt.execute_query("CREATE TABLE users (id INT, score INT)")
3626            .unwrap();
3627        for id in 0..5 {
3628            rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
3629                .unwrap();
3630        }
3631        rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
3632            .unwrap();
3633
3634        let updated = rt
3635            .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
3636            .unwrap();
3637        assert_eq!(updated.affected_rows, 3);
3638
3639        let selected = rt
3640            .execute_query("SELECT id, score FROM users ORDER BY id")
3641            .unwrap();
3642        let scores: Vec<(i64, i64)> = selected
3643            .result
3644            .records
3645            .iter()
3646            .map(|record| {
3647                let id = match record.get("id").unwrap() {
3648                    Value::Integer(value) => *value,
3649                    other => panic!("expected integer id, got {other:?}"),
3650                };
3651                let score = match record.get("score").unwrap() {
3652                    Value::Integer(value) => *value,
3653                    other => panic!("expected integer score, got {other:?}"),
3654                };
3655                (id, score)
3656            })
3657            .collect();
3658        assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3659    }
3660
3661    /// Drives UPDATE through the shared `DmlTargetScan` module — the
3662    /// same code path DELETE uses (#51, #52). Exercises the indexed
3663    /// equality fast-path (WHERE id = N with a HASH index), the
3664    /// unindexed range scan (WHERE score > N), and the no-WHERE
3665    /// full-scan branch to confirm the extracted "find target rows"
3666    /// loop preserves affected-row counts and the resulting row state.
3667    #[test]
3668    fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3669        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3670        rt.execute_query("CREATE TABLE items (id INT, score INT)")
3671            .unwrap();
3672        for id in 0..5 {
3673            rt.execute_query(&format!(
3674                "INSERT INTO items (id, score) VALUES ({id}, {})",
3675                id * 10
3676            ))
3677            .unwrap();
3678        }
3679        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3680            .unwrap();
3681
3682        // Indexed equality UPDATE — hits the hash fast-path inside
3683        // DmlTargetScan::find_target_ids. id=2 has score=20, drop it
3684        // below the score>25 cutoff so the next assertion stays clean.
3685        let updated_one = rt
3686            .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3687            .unwrap();
3688        assert_eq!(updated_one.affected_rows, 1);
3689
3690        // Unindexed scan UPDATE — bumps everyone with score > 25,
3691        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
3692        // zoned/full-scan branch.
3693        let updated_many = rt
3694            .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3695            .unwrap();
3696        assert_eq!(updated_many.affected_rows, 2);
3697
3698        let snapshot = rt
3699            .execute_query("SELECT id, score FROM items ORDER BY id")
3700            .unwrap();
3701        let pairs: Vec<(i64, i64)> = snapshot
3702            .result
3703            .records
3704            .iter()
3705            .map(|record| {
3706                let id = match record.get("id").unwrap() {
3707                    Value::Integer(value) => *value,
3708                    other => panic!("expected integer id, got {other:?}"),
3709                };
3710                let score = match record.get("score").unwrap() {
3711                    Value::Integer(value) => *value,
3712                    other => panic!("expected integer score, got {other:?}"),
3713                };
3714                (id, score)
3715            })
3716            .collect();
3717        assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3718
3719        // Full-scan UPDATE with no WHERE rewrites every remaining row.
3720        let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3721        assert_eq!(updated_all.affected_rows, 5);
3722        let after = rt
3723            .execute_query("SELECT score FROM items ORDER BY id")
3724            .unwrap();
3725        let scores: Vec<i64> = after
3726            .result
3727            .records
3728            .iter()
3729            .map(|record| match record.get("score").unwrap() {
3730                Value::Integer(value) => *value,
3731                other => panic!("expected integer score, got {other:?}"),
3732            })
3733            .collect();
3734        assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3735    }
3736
3737    /// Drives DELETE through the new `DmlTargetScan` module. Exercises
3738    /// both the index fast-path (WHERE id = N with a HASH index) and
3739    /// the unindexed scan path (WHERE score > N) to confirm the
3740    /// extracted "find target rows" loop preserves the affected-row
3741    /// count and which rows survive.
3742    #[test]
3743    fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3744        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3745        rt.execute_query("CREATE TABLE items (id INT, score INT)")
3746            .unwrap();
3747        for id in 0..5 {
3748            rt.execute_query(&format!(
3749                "INSERT INTO items (id, score) VALUES ({id}, {})",
3750                id * 10
3751            ))
3752            .unwrap();
3753        }
3754        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3755            .unwrap();
3756
3757        // Indexed equality DELETE — hits the hash fast-path inside
3758        // DmlTargetScan::find_target_ids.
3759        let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3760        assert_eq!(deleted_one.affected_rows, 1);
3761
3762        // Unindexed scan DELETE — drops everyone with score > 25,
3763        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
3764        // zoned/full-scan branch.
3765        let deleted_many = rt
3766            .execute_query("DELETE FROM items WHERE score > 25")
3767            .unwrap();
3768        assert_eq!(deleted_many.affected_rows, 2);
3769
3770        let surviving = rt
3771            .execute_query("SELECT id FROM items ORDER BY id")
3772            .unwrap();
3773        let ids: Vec<i64> = surviving
3774            .result
3775            .records
3776            .iter()
3777            .map(|record| match record.get("id").unwrap() {
3778                Value::Integer(value) => *value,
3779                other => panic!("expected integer id, got {other:?}"),
3780            })
3781            .collect();
3782        assert_eq!(ids, vec![0, 1]);
3783
3784        // Sanity: full-scan DELETE with no WHERE clears the rest.
3785        let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3786        assert_eq!(deleted_rest.affected_rows, 2);
3787        let empty = rt.execute_query("SELECT id FROM items").unwrap();
3788        assert!(empty.result.records.is_empty());
3789    }
3790
3791    /// CollectionContract gate (#49 + #50): APPEND ONLY tables accept
3792    /// INSERT but reject UPDATE and DELETE with the documented
3793    /// operator-facing error strings. Drives all three DML verbs so
3794    /// the centralized gate is exercised end-to-end.
3795    #[test]
3796    fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3797        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3798        rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3799            .unwrap();
3800
3801        // INSERT must succeed — APPEND ONLY exists precisely to allow
3802        // appends. The gate should be a no-op for INSERT.
3803        let inserted = rt
3804            .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3805            .unwrap();
3806        assert_eq!(inserted.affected_rows, 1);
3807
3808        // UPDATE is rejected with the gate's UPDATE-specific message.
3809        let update_err = rt
3810            .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3811            .unwrap_err();
3812        let msg = format!("{update_err}");
3813        assert!(
3814            msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3815            "expected UPDATE rejection message, got: {msg}"
3816        );
3817
3818        // DELETE is rejected with the gate's DELETE-specific message.
3819        let delete_err = rt
3820            .execute_query("DELETE FROM events WHERE id = 1")
3821            .unwrap_err();
3822        let msg = format!("{delete_err}");
3823        assert!(
3824            msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3825            "expected DELETE rejection message, got: {msg}"
3826        );
3827
3828        // Row should still be present — neither rejected mutation
3829        // touched storage.
3830        let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3831        assert_eq!(surviving.result.records.len(), 1);
3832    }
3833
3834    /// CollectionContract gate: tables without an APPEND ONLY contract
3835    /// permit INSERT, UPDATE, and DELETE — the gate's default branch
3836    /// is a true pass-through, not an accidental block.
3837    #[test]
3838    fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3839        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3840        rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3841            .unwrap();
3842
3843        rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3844            .unwrap();
3845        let updated = rt
3846            .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3847            .unwrap();
3848        assert_eq!(updated.affected_rows, 1);
3849        let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3850        assert_eq!(deleted.affected_rows, 1);
3851    }
3852
3853    #[test]
3854    fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3855        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3856        rt.execute_query(
3857            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3858        )
3859        .unwrap();
3860
3861        let inserted = rt
3862            .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3863            .unwrap();
3864        assert_eq!(inserted.affected_rows, 1);
3865
3866        let events = queue_payloads(&rt, "audit_log");
3867        assert_eq!(events.len(), 1);
3868        let event = events[0].as_object().expect("event payload object");
3869        assert!(event
3870            .get("event_id")
3871            .and_then(crate::json::Value::as_str)
3872            .is_some_and(|value| !value.is_empty()));
3873        assert_eq!(
3874            event.get("op").and_then(crate::json::Value::as_str),
3875            Some("insert")
3876        );
3877        assert_eq!(
3878            event.get("collection").and_then(crate::json::Value::as_str),
3879            Some("users")
3880        );
3881        assert_eq!(
3882            event.get("id").and_then(crate::json::Value::as_u64),
3883            Some(7)
3884        );
3885        assert!(event
3886            .get("ts")
3887            .and_then(crate::json::Value::as_u64)
3888            .is_some());
3889        assert!(event
3890            .get("lsn")
3891            .and_then(crate::json::Value::as_u64)
3892            .is_some());
3893        assert!(matches!(
3894            event.get("tenant"),
3895            Some(crate::json::Value::Null)
3896        ));
3897        assert!(matches!(
3898            event.get("before"),
3899            Some(crate::json::Value::Null)
3900        ));
3901        let after = event
3902            .get("after")
3903            .and_then(crate::json::Value::as_object)
3904            .expect("after object");
3905        assert_eq!(
3906            after.get("id").and_then(crate::json::Value::as_u64),
3907            Some(7)
3908        );
3909        assert_eq!(
3910            after.get("email").and_then(crate::json::Value::as_str),
3911            Some("a@example.com")
3912        );
3913    }
3914
3915    #[test]
3916    fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3917        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3918        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3919            .unwrap();
3920
3921        rt.execute_query(
3922            "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
3923        )
3924        .unwrap();
3925
3926        let events = queue_payloads(&rt, "users_events");
3927        assert_eq!(events.len(), 2);
3928        let mut previous_lsn = 0;
3929        for (event, expected_id) in events.iter().zip([1_u64, 2]) {
3930            let object = event.as_object().expect("event payload object");
3931            assert_eq!(
3932                object.get("op").and_then(crate::json::Value::as_str),
3933                Some("insert")
3934            );
3935            assert_eq!(
3936                object.get("id").and_then(crate::json::Value::as_u64),
3937                Some(expected_id)
3938            );
3939            let lsn = object
3940                .get("lsn")
3941                .and_then(crate::json::Value::as_u64)
3942                .expect("event lsn");
3943            assert!(
3944                lsn > previous_lsn,
3945                "event LSNs should increase in row order"
3946            );
3947            previous_lsn = lsn;
3948            let after = object
3949                .get("after")
3950                .and_then(crate::json::Value::as_object)
3951                .expect("after object");
3952            assert_eq!(
3953                after.get("id").and_then(crate::json::Value::as_u64),
3954                Some(expected_id)
3955            );
3956        }
3957    }
3958
3959    #[test]
3960    fn claim_metrics_increment_skipped_locked_without_counting_miss() {
3961        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3962        rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
3963            .expect("create table");
3964        // ADR 0063: index-backed claim ordering on `rank`.
3965        rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
3966            .expect("create index");
3967        rt.execute_query(
3968            "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
3969             (1, 10, 'ready'), (2, 20, 'ready')",
3970        )
3971        .expect("insert rows");
3972
3973        let claim_lock = rt
3974            .inner
3975            .rmw_locks
3976            .lock_for("claim_metric_locked", "__table_claim_update__");
3977        let _guard = claim_lock.lock();
3978        let updated = rt
3979            .execute_query(
3980                "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
3981                 CLAIM LIMIT 2 ORDER BY rank ASC",
3982            )
3983            .expect("claim skips locked candidates");
3984
3985        assert_eq!(updated.affected_rows, 0);
3986        let snapshot = rt.claim_telemetry_snapshot();
3987        assert_eq!(
3988            claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
3989            1
3990        );
3991        assert_eq!(
3992            claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
3993            2
3994        );
3995        assert_eq!(
3996            claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
3997            0
3998        );
3999        assert_eq!(
4000            snapshot.skipped_locked.len(),
4001            1,
4002            "skipped-lock labels stay bounded to collection/model"
4003        );
4004    }
4005
4006    fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
4007        let result = rt
4008            .execute_query(&format!("QUEUE PEEK {queue} 10"))
4009            .expect("peek queue");
4010        result
4011            .result
4012            .records
4013            .iter()
4014            .map(
4015                |record| match record.get("payload").expect("payload column") {
4016                    Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
4017                    other => panic!("expected JSON queue payload, got {other:?}"),
4018                },
4019            )
4020            .collect()
4021    }
4022
4023    // ── #112: auto-index user `id` on first insert ─────────────────────
4024
4025    /// First insert into a fresh collection that carries a column named
4026    /// `id` registers an implicit HASH index on `id`. Subsequent inserts
4027    /// populate it transparently, and `WHERE id = N` lookups exercise
4028    /// the hash-index fast path in `DmlTargetScan::find_target_ids`.
4029    ///
4030    /// This is the load-bearing acceptance test for #112 — without the
4031    /// hook, `find_index_for_column` returns `None` and DELETE/UPDATE
4032    /// fall through to a full segment scan (the 4× perf gap documented
4033    /// in `docs/perf/delete-sequential-2026-05-06.md`).
4034    #[test]
4035    fn auto_index_id_fires_on_first_insert() {
4036        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4037        rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
4038            .unwrap();
4039
4040        // Pre-condition: no index on `id` yet.
4041        assert!(
4042            rt.index_store_ref()
4043                .find_index_for_column("bench_users", "id")
4044                .is_none(),
4045            "freshly created collection should not have an `id` index"
4046        );
4047
4048        // Single-row INSERT — drives `MutationEngine::append_one`.
4049        rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
4050            .unwrap();
4051
4052        // Post-condition: hash index registered on `id`.
4053        let registered = rt
4054            .index_store_ref()
4055            .find_index_for_column("bench_users", "id")
4056            .expect("auto-index hook should have registered idx_id on first insert");
4057        assert_eq!(registered.name, "idx_id");
4058        assert_eq!(registered.collection, "bench_users");
4059        assert_eq!(registered.columns, vec!["id".to_string()]);
4060        assert!(matches!(
4061            registered.method,
4062            super::super::index_store::IndexMethodKind::Hash
4063        ));
4064
4065        // Subsequent inserts populate the index; `WHERE id = N` should
4066        // resolve via the hash fast path and round-trip every row.
4067        for id in 2..=5 {
4068            rt.execute_query(&format!(
4069                "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
4070                id * 10
4071            ))
4072            .unwrap();
4073        }
4074        for id in 1..=5 {
4075            let result = rt
4076                .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
4077                .unwrap();
4078            assert_eq!(
4079                result.result.records.len(),
4080                1,
4081                "id={id} should match one row"
4082            );
4083        }
4084
4085        // Delete via the hash fast-path — exactly the bench scenario the
4086        // perf doc identified as the 4× regression. With the index
4087        // present, `find_target_ids` short-circuits before
4088        // `for_each_entity_zoned` runs.
4089        let deleted = rt
4090            .execute_query("DELETE FROM bench_users WHERE id = 3")
4091            .unwrap();
4092        assert_eq!(deleted.affected_rows, 1);
4093    }
4094
4095    /// Bulk INSERT (the multi-row VALUES path) drives
4096    /// `MutationEngine::append_batch`. The hook must fire there too —
4097    /// otherwise the batch entry points (gRPC binary bulk, HTTP bulk,
4098    /// wire bulk INSERT) skip auto-indexing entirely.
4099    #[test]
4100    fn auto_index_id_fires_on_first_bulk_insert() {
4101        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4102        rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
4103            .unwrap();
4104
4105        rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
4106            .unwrap();
4107
4108        let registered = rt
4109            .index_store_ref()
4110            .find_index_for_column("bench_bulk", "id")
4111            .expect("auto-index hook should fire on first bulk insert");
4112        assert_eq!(registered.name, "idx_id");
4113
4114        // Every row populated via `index_entity_insert_batch`.
4115        for id in 1..=3 {
4116            let result = rt
4117                .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
4118                .unwrap();
4119            assert_eq!(result.result.records.len(), 1);
4120        }
4121    }
4122
4123    /// Hook is a no-op when the row carries no `id` column. Conservative
4124    /// match (case-sensitive `id`) — `Id`, `ID`, and `rid`
4125    /// don't trigger it.
4126    #[test]
4127    fn auto_index_id_skips_when_no_id_column() {
4128        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4129        rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
4130            .unwrap();
4131        rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
4132            .unwrap();
4133
4134        assert!(rt
4135            .index_store_ref()
4136            .find_index_for_column("plain", "id")
4137            .is_none());
4138        assert!(rt
4139            .index_store_ref()
4140            .find_index_for_column("plain", "uid")
4141            .is_none());
4142    }
4143
4144    /// Hook only fires once per collection. If an explicit
4145    /// `CREATE INDEX ... USING BTREE` already covers `id`, the hook
4146    /// detects it via `find_index_for_column` and does NOT clobber it
4147    /// with a HASH index on the next insert.
4148    #[test]
4149    fn auto_index_id_skips_when_index_already_exists() {
4150        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4151        rt.execute_query("CREATE TABLE pre (id INT, score INT)")
4152            .unwrap();
4153        // User-declared BTREE index on `id` before any insert.
4154        rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
4155            .unwrap();
4156        rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
4157            .unwrap();
4158
4159        let registered = rt
4160            .index_store_ref()
4161            .find_index_for_column("pre", "id")
4162            .expect("user index should still be there");
4163        assert_eq!(
4164            registered.name, "user_idx",
4165            "auto-index hook must not overwrite an existing index"
4166        );
4167    }
4168
4169    /// Implicit `idx_id` is reaped when the collection drops. The
4170    /// existing `execute_drop_table` walks `list_indices` and drops every
4171    /// entry — confirm the auto-created index participates.
4172    #[test]
4173    fn auto_index_id_dropped_with_collection() {
4174        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4175        rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
4176            .unwrap();
4177        rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
4178            .unwrap();
4179        assert!(rt
4180            .index_store_ref()
4181            .find_index_for_column("ephemeral", "id")
4182            .is_some());
4183
4184        rt.execute_query("DROP TABLE ephemeral").unwrap();
4185
4186        assert!(
4187            rt.index_store_ref()
4188                .find_index_for_column("ephemeral", "id")
4189                .is_none(),
4190            "implicit `idx_id` must be reaped when its collection drops"
4191        );
4192    }
4193
4194    /// Opt-out via `RedDBOptions::with_auto_index_id(false)` (which
4195    /// forwards to `UnifiedStoreConfig::auto_index_id`). With the knob
4196    /// off, first insert leaves the collection without an `id` index —
4197    /// DELETE/UPDATE fall back to the scan path.
4198    #[test]
4199    fn auto_index_id_disabled_by_config() {
4200        let opts = RedDBOptions::in_memory().with_auto_index_id(false);
4201        let rt = RedDBRuntime::with_options(opts).unwrap();
4202
4203        rt.execute_query("CREATE TABLE off (id INT, score INT)")
4204            .unwrap();
4205        rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
4206            .unwrap();
4207
4208        assert!(
4209            rt.index_store_ref()
4210                .find_index_for_column("off", "id")
4211                .is_none(),
4212            "with auto_index_id=false, no implicit index should be created"
4213        );
4214    }
4215
4216    // ── #293: UPDATE / DELETE events ─────────────────────────────────────
4217
4218    #[test]
4219    fn update_single_row_emits_update_event() {
4220        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4221        rt.execute_query(
4222            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
4223        )
4224        .unwrap();
4225        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4226            .unwrap();
4227
4228        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
4229            .unwrap();
4230
4231        let events = queue_payloads(&rt, "audit_log");
4232        assert_eq!(events.len(), 1, "expected exactly 1 update event");
4233        let event = events[0].as_object().expect("event payload object");
4234        assert_eq!(
4235            event.get("op").and_then(crate::json::Value::as_str),
4236            Some("update")
4237        );
4238        assert_eq!(
4239            event.get("collection").and_then(crate::json::Value::as_str),
4240            Some("users")
4241        );
4242        assert!(event
4243            .get("event_id")
4244            .and_then(crate::json::Value::as_str)
4245            .is_some_and(|v| !v.is_empty()));
4246        let before = event
4247            .get("before")
4248            .and_then(crate::json::Value::as_object)
4249            .expect("before must be an object");
4250        let after = event
4251            .get("after")
4252            .and_then(crate::json::Value::as_object)
4253            .expect("after must be an object");
4254        assert_eq!(
4255            before.get("name").and_then(crate::json::Value::as_str),
4256            Some("Alice"),
4257            "before.name should be the old value"
4258        );
4259        assert_eq!(
4260            after.get("name").and_then(crate::json::Value::as_str),
4261            Some("Bob"),
4262            "after.name should be the new value"
4263        );
4264    }
4265
4266    #[test]
4267    fn update_event_only_includes_changed_fields() {
4268        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4269        rt.execute_query(
4270            "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
4271        )
4272        .unwrap();
4273        rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
4274            .unwrap();
4275
4276        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
4277            .unwrap();
4278
4279        let events = queue_payloads(&rt, "evts");
4280        assert_eq!(events.len(), 1);
4281        let event = events[0].as_object().unwrap();
4282        let before = event
4283            .get("before")
4284            .and_then(crate::json::Value::as_object)
4285            .unwrap();
4286        let after = event
4287            .get("after")
4288            .and_then(crate::json::Value::as_object)
4289            .unwrap();
4290        // Only changed field included.
4291        assert!(
4292            before.contains_key("name"),
4293            "before must include changed field"
4294        );
4295        assert!(
4296            after.contains_key("name"),
4297            "after must include changed field"
4298        );
4299        // Unchanged fields must not appear.
4300        assert!(
4301            !before.contains_key("email"),
4302            "before must not include unchanged email"
4303        );
4304        assert!(
4305            !after.contains_key("email"),
4306            "after must not include unchanged email"
4307        );
4308    }
4309
4310    #[test]
4311    fn multi_row_update_emits_one_event_per_row() {
4312        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4313        rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
4314            .unwrap();
4315        rt.execute_query(
4316            "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
4317        )
4318        .unwrap();
4319
4320        rt.execute_query("UPDATE items SET status = 'done'")
4321            .unwrap();
4322
4323        let events = queue_payloads(&rt, "evts");
4324        assert_eq!(events.len(), 3, "expected one update event per row");
4325        for event in &events {
4326            let obj = event.as_object().unwrap();
4327            assert_eq!(
4328                obj.get("op").and_then(crate::json::Value::as_str),
4329                Some("update")
4330            );
4331        }
4332    }
4333
4334    #[test]
4335    fn delete_single_row_emits_delete_event() {
4336        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4337        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
4338            .unwrap();
4339        rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
4340            .unwrap();
4341
4342        rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
4343
4344        let events = queue_payloads(&rt, "del_log");
4345        assert_eq!(events.len(), 1);
4346        let event = events[0].as_object().expect("event payload object");
4347        assert_eq!(
4348            event.get("op").and_then(crate::json::Value::as_str),
4349            Some("delete")
4350        );
4351        assert_eq!(
4352            event.get("collection").and_then(crate::json::Value::as_str),
4353            Some("users")
4354        );
4355        assert!(event
4356            .get("event_id")
4357            .and_then(crate::json::Value::as_str)
4358            .is_some_and(|v| !v.is_empty()));
4359        let before = event
4360            .get("before")
4361            .and_then(crate::json::Value::as_object)
4362            .expect("before must be an object for delete");
4363        assert_eq!(
4364            before.get("id").and_then(crate::json::Value::as_u64),
4365            Some(42)
4366        );
4367        assert_eq!(
4368            before.get("name").and_then(crate::json::Value::as_str),
4369            Some("Alice")
4370        );
4371        assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
4372    }
4373
4374    #[test]
4375    fn multi_row_delete_emits_one_event_per_row() {
4376        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4377        rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
4378            .unwrap();
4379        rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
4380            .unwrap();
4381
4382        rt.execute_query("DELETE FROM items").unwrap();
4383
4384        let events = queue_payloads(&rt, "del_log");
4385        assert_eq!(events.len(), 3, "expected one delete event per deleted row");
4386        for event in &events {
4387            let obj = event.as_object().unwrap();
4388            assert_eq!(
4389                obj.get("op").and_then(crate::json::Value::as_str),
4390                Some("delete")
4391            );
4392            assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
4393        }
4394    }
4395
4396    #[test]
4397    fn ops_filter_update_does_not_emit_on_insert_or_delete() {
4398        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4399        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
4400            .unwrap();
4401
4402        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4403            .unwrap();
4404        rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
4405
4406        let events = queue_payloads(&rt, "evts");
4407        assert!(
4408            events.is_empty(),
4409            "UPDATE-only filter must not emit INSERT or DELETE events"
4410        );
4411    }
4412
4413    // ── SUPPRESS EVENTS ────────────────────────────────────────────────────
4414
4415    #[test]
4416    fn suppress_events_on_insert_emits_no_events() {
4417        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4418        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4419            .unwrap();
4420
4421        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4422            .unwrap();
4423
4424        let events = queue_payloads(&rt, "evts");
4425        assert!(
4426            events.is_empty(),
4427            "SUPPRESS EVENTS must prevent INSERT events"
4428        );
4429    }
4430
4431    #[test]
4432    fn suppress_events_on_update_emits_no_events() {
4433        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4434        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4435            .unwrap();
4436        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
4437            .unwrap();
4438        // drain the INSERT event
4439        let _ = queue_payloads(&rt, "evts");
4440        // Force pop to drain; simpler: just check new count after UPDATE
4441        rt.execute_query("QUEUE PURGE evts").unwrap();
4442
4443        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
4444            .unwrap();
4445
4446        let events = queue_payloads(&rt, "evts");
4447        assert!(
4448            events.is_empty(),
4449            "SUPPRESS EVENTS must prevent UPDATE events"
4450        );
4451    }
4452
4453    #[test]
4454    fn suppress_events_on_delete_emits_no_events() {
4455        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4456        rt.execute_query(
4457            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
4458        )
4459        .unwrap();
4460        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4461            .unwrap();
4462
4463        rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
4464            .unwrap();
4465
4466        let events = queue_payloads(&rt, "evts");
4467        assert!(
4468            events.is_empty(),
4469            "SUPPRESS EVENTS must prevent DELETE events"
4470        );
4471    }
4472
4473    #[test]
4474    fn normal_insert_after_suppress_still_emits() {
4475        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4476        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
4477            .unwrap();
4478
4479        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
4480            .unwrap();
4481        rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
4482            .unwrap();
4483
4484        let events = queue_payloads(&rt, "evts");
4485        assert_eq!(
4486            events.len(),
4487            1,
4488            "only the non-suppressed INSERT should emit"
4489        );
4490        assert_eq!(
4491            events[0].get("id").and_then(crate::json::Value::as_u64),
4492            Some(2)
4493        );
4494    }
4495}