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