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
57const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
58pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
59pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
60
61#[derive(Clone)]
62pub(super) struct CompiledUpdateAssignment {
63    column: String,
64    expr: Expr,
65    compound_op: Option<BinOp>,
66    metadata_key: Option<&'static str>,
67    row_rule: Option<RowUpdateColumnRule>,
68}
69
70pub(super) struct CompiledUpdatePlan {
71    pub(super) static_field_assignments: Vec<(String, Value)>,
72    pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
73    dynamic_assignments: Vec<CompiledUpdateAssignment>,
74    row_contract_plan: Option<RowUpdateContractPlan>,
75    row_modified_columns: Vec<String>,
76    row_touches_unique_columns: bool,
77}
78
79#[derive(Default)]
80pub(super) struct MaterializedUpdateAssignments {
81    pub(super) dynamic_field_assignments: Vec<(String, Value)>,
82    pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
83}
84
85fn resolve_excluded_assignment(
86    expr: &Expr,
87    excluded_fields: &[(String, Value)],
88) -> RedDBResult<Expr> {
89    match expr {
90        Expr::Column {
91            field: FieldRef::TableColumn { table, column },
92            span,
93        } if table.eq_ignore_ascii_case("excluded") => {
94            let value = excluded_fields
95                .iter()
96                .find(|(name, _)| name.eq_ignore_ascii_case(column))
97                .map(|(_, value)| value.clone())
98                .ok_or_else(|| {
99                    RedDBError::Query(format!(
100                        "ON CONFLICT EXCLUDED references unknown insert column '{column}'"
101                    ))
102                })?;
103            Ok(Expr::Literal { value, span: *span })
104        }
105        Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
106            op: *op,
107            lhs: Box::new(resolve_excluded_assignment(lhs, excluded_fields)?),
108            rhs: Box::new(resolve_excluded_assignment(rhs, excluded_fields)?),
109            span: *span,
110        }),
111        _ => Ok(expr.clone()),
112    }
113}
114
115fn conflict_columns_match(index_columns: &[String], target_columns: &[String]) -> bool {
116    index_columns.len() == target_columns.len()
117        && index_columns.iter().all(|index_column| {
118            target_columns
119                .iter()
120                .any(|target_column| target_column.eq_ignore_ascii_case(index_column))
121        })
122}
123
124fn conflict_index_key(fields: &[(String, Value)], columns: &[String]) -> Option<Vec<Value>> {
125    columns
126        .iter()
127        .map(|column| {
128            super::index_store::index_field_value(fields, column)
129                .filter(|value| !matches!(value.as_ref(), Value::Null))
130                .map(|value| value.into_owned())
131        })
132        .collect()
133}
134
135fn document_on_conflict_fields(
136    stored_fields: &[(String, Value)],
137    body: &crate::json::Value,
138) -> RedDBResult<Vec<(String, Value)>> {
139    let mut fields = stored_fields.to_vec();
140    if let crate::json::Value::Object(object) = body {
141        for (name, value) in object {
142            fields.push((
143                name.clone(),
144                crate::application::entity::json_to_storage_value(value)?,
145            ));
146        }
147    }
148    Ok(fields)
149}
150
151impl RedDBRuntime {
152    fn conflict_unique_indexes(
153        &self,
154        collection: &str,
155        target: Option<&[String]>,
156    ) -> RedDBResult<Vec<super::index_store::RegisteredIndex>> {
157        let mut indexes = self
158            .index_store_ref()
159            .list_indices(collection)
160            .into_iter()
161            .filter(|index| index.unique)
162            .collect::<Vec<_>>();
163        if let Some(target) = target {
164            indexes.retain(|index| conflict_columns_match(&index.columns, target));
165            if indexes.is_empty() {
166                return Err(RedDBError::Query(format!(
167                    "no unique index on collection '{}' matches ON CONFLICT ({})",
168                    collection,
169                    target.join(", ")
170                )));
171            }
172        }
173        Ok(indexes)
174    }
175
176    fn unique_index_conflict_id(
177        &self,
178        collection: &str,
179        fields: &[(String, Value)],
180        target: Option<&[String]>,
181    ) -> RedDBResult<Option<EntityId>> {
182        let indexes = self.conflict_unique_indexes(collection, target)?;
183        let Some(manager) = self.inner.db.store().get_collection(collection) else {
184            return Ok(None);
185        };
186        for index in indexes {
187            let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
188                continue;
189            };
190            for entity in manager.query_all(|_| true) {
191                let Some(record) = runtime_any_record_from_entity_ref(&entity) else {
192                    continue;
193                };
194                let existing_fields = record
195                    .iter_fields()
196                    .map(|(name, value)| (name.as_ref().to_string(), value.clone()))
197                    .collect::<Vec<_>>();
198                if self.snapshot_manager().is_aborted(entity.xmin) {
199                    self.index_store_ref()
200                        .index_entity_delete(collection, entity.id, &existing_fields)
201                        .map_err(RedDBError::Internal)?;
202                    continue;
203                }
204                if conflict_index_key(&existing_fields, &index.columns)
205                    .is_some_and(|existing_key| existing_key == proposed_key)
206                {
207                    let conflicting_xid = entity.xmin;
208                    if self.snapshot_manager().is_active(conflicting_xid)
209                        && !self.own_transaction_xids().contains(&conflicting_xid)
210                    {
211                        return Err(RedDBError::Query(format!(
212                            "serialization conflict: ON CONFLICT key in '{collection}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves"
213                        )));
214                    }
215                    return Ok(Some(entity.id));
216                }
217            }
218        }
219        Ok(None)
220    }
221
222    fn on_conflict_unique_index_conflict_id(
223        &self,
224        collection: &str,
225        fields: &[(String, Value)],
226        target: Option<&[String]>,
227    ) -> RedDBResult<Option<EntityId>> {
228        let selected = self.unique_index_conflict_id(collection, fields, target)?;
229        if selected.is_some() || target.is_none() {
230            return Ok(selected);
231        }
232        if self
233            .unique_index_conflict_id(collection, fields, None)?
234            .is_some()
235        {
236            return Err(RedDBError::Query(format!(
237                "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
238                target.unwrap_or_default().join(", ")
239            )));
240        }
241        Ok(None)
242    }
243
244    fn has_unique_index_conflict_with_fields(
245        &self,
246        collection: &str,
247        fields: &[(String, Value)],
248        existing_fields: &[Vec<(String, Value)>],
249        target: Option<&[String]>,
250    ) -> RedDBResult<bool> {
251        for index in self.conflict_unique_indexes(collection, target)? {
252            let Some(proposed_key) = conflict_index_key(fields, &index.columns) else {
253                continue;
254            };
255            if existing_fields.iter().any(|existing| {
256                conflict_index_key(existing, &index.columns)
257                    .is_some_and(|existing_key| existing_key == proposed_key)
258            }) {
259                return Ok(true);
260            }
261        }
262        Ok(false)
263    }
264
265    fn has_on_conflict_unique_index_conflict_with_fields(
266        &self,
267        collection: &str,
268        fields: &[(String, Value)],
269        existing_fields: &[Vec<(String, Value)>],
270        target: Option<&[String]>,
271    ) -> RedDBResult<bool> {
272        let selected = self.has_unique_index_conflict_with_fields(
273            collection,
274            fields,
275            existing_fields,
276            target,
277        )?;
278        if selected || target.is_none() {
279            return Ok(selected);
280        }
281        if self.has_unique_index_conflict_with_fields(collection, fields, existing_fields, None)? {
282            return Err(RedDBError::Query(format!(
283                "unique index conflict on collection '{collection}' is not covered by ON CONFLICT ({})",
284                target.unwrap_or_default().join(", ")
285            )));
286        }
287        Ok(false)
288    }
289
290    fn execute_unique_index_conflict_update(
291        &self,
292        raw_query: &str,
293        insert: &InsertQuery,
294        conflict_id: EntityId,
295        target: UpdateTarget,
296        excluded_fields: &[(String, Value)],
297        assignments: &[(String, Expr)],
298    ) -> RedDBResult<RuntimeQueryResult> {
299        let assignment_exprs = assignments
300            .iter()
301            .map(|(column, expr)| {
302                Ok((
303                    column.clone(),
304                    resolve_excluded_assignment(expr, excluded_fields)?,
305                ))
306            })
307            .collect::<RedDBResult<Vec<_>>>()?;
308        let literal_assignments = assignment_exprs
309            .iter()
310            .filter_map(|(column, expr)| {
311                fold_expr_to_value(expr.clone())
312                    .ok()
313                    .map(|value| (column.clone(), value))
314            })
315            .collect();
316        let update = UpdateQuery {
317            table: insert.table.clone(),
318            target,
319            compound_assignment_ops: vec![None; assignment_exprs.len()],
320            assignment_exprs,
321            assignments: literal_assignments,
322            where_expr: None,
323            filter: Some(Filter::Compare {
324                field: FieldRef::column("", "rid"),
325                op: CompareOp::Eq,
326                value: Value::UnsignedInteger(conflict_id.raw()),
327            }),
328            ttl_ms: None,
329            expires_at_ms: None,
330            with_metadata: Vec::new(),
331            returning: insert.returning.clone(),
332            claim_limit: None,
333            claim_exact: false,
334            order_by: Vec::new(),
335            limit: None,
336            suppress_events: insert.suppress_events,
337        };
338        self.execute_update(raw_query, &update)
339    }
340
341    /// ADR 0067 (#1710): resolve the model of an unmarked bare-VALUES
342    /// INSERT from the catalog, making the marker rule real — *a model
343    /// marker exists only where it disambiguates what the catalog cannot
344    /// know.*
345    ///
346    /// `INSERT INTO c VALUES ({…})` has no column list and no model marker,
347    /// so the parser leaves `entity_type = Row` with an empty column list.
348    /// Only that exact shape is a candidate for inference — every other
349    /// INSERT (an explicit column list, or an explicit `DOCUMENT` / `NODE`
350    /// / `VECTOR` / … marker) is returned untouched.
351    ///
352    /// * existing **document** collection → rewritten to a `DOCUMENT`
353    ///   insert so the body routes through document creation, exactly the
354    ///   path the explicit marker takes;
355    /// * existing **non-document** collection → the bare form does not
356    ///   apply; reported with a model-oriented error;
357    /// * unknown collection → didactic error naming both recourses (the
358    ///   `DOCUMENT` assertion form or `CREATE DOCUMENT` first).
359    fn infer_unmarked_document_insert(
360        &self,
361        query: &InsertQuery,
362    ) -> RedDBResult<Option<InsertQuery>> {
363        if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
364            return Ok(None);
365        }
366        match self
367            .db()
368            .collection_contract_arc(&query.table)
369            .map(|contract| contract.declared_model)
370        {
371            Some(crate::catalog::CollectionModel::Document) => {
372                let mut rewritten = query.clone();
373                rewritten.entity_type = InsertEntityType::Document;
374                rewritten.columns = vec!["body".to_string()];
375                Ok(Some(rewritten))
376            }
377            Some(other) => Err(RedDBError::InvalidOperation(format!(
378                "collection '{table}' is declared as '{model}'; the bare \
379                 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
380                 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
381                 to insert into a {model} collection",
382                table = query.table,
383                model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
384            ))),
385            None => Err(RedDBError::InvalidOperation(format!(
386                "collection '{table}' does not exist and the INSERT carries no model marker; \
387                 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
388                 (idempotent), or run `CREATE DOCUMENT {table}` first",
389                table = query.table,
390            ))),
391        }
392    }
393
394    /// Resolve the model of an unmarked UPDATE from the catalog (ADR 0067,
395    /// #1711). The `DOCUMENTS` / `ROWS` / `KV` UPDATE markers were removed, so
396    /// an unmarked UPDATE parses to the `Rows` target; the catalog is the
397    /// single source of truth for the collection's model. An existing document
398    /// collection is rewritten to the `Documents` target (the runtime routes
399    /// body / patch semantics off the target the explicit marker used to set);
400    /// a KV collection to `Kv` so the key-immutability guard still fires. Table
401    /// and unknown collections keep the `Rows` default.
402    ///
403    /// Dotted assignment targets (`SET a.b.c = …`) parse for every target — the
404    /// model is not a parse-time fact — but they are legal only on a document
405    /// collection and rejected here with a clear model-oriented error off it.
406    fn resolve_unmarked_update_target(
407        &self,
408        query: &UpdateQuery,
409    ) -> RedDBResult<Option<UpdateQuery>> {
410        // NODES / EDGES are explicit, user-declared graph targets — never
411        // inferred. Dotted paths off a graph target are still off-model.
412        if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
413            ensure_update_dotted_targets_allowed(query, false)?;
414            return Ok(None);
415        }
416        let declared_model = self
417            .db()
418            .collection_contract_arc(&query.table)
419            .map(|contract| contract.declared_model);
420        let is_document = matches!(
421            declared_model,
422            Some(crate::catalog::CollectionModel::Document)
423        );
424        ensure_update_dotted_targets_allowed(query, is_document)?;
425        let inferred = match declared_model {
426            Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
427            Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
428            _ => return Ok(None),
429        };
430        let mut rewritten = query.clone();
431        rewritten.target = inferred;
432        Ok(Some(rewritten))
433    }
434
435    /// Execute INSERT INTO table [entity_type] (cols) VALUES (vals), ...
436    ///
437    /// Each row in `query.values` is zipped with `query.columns` to produce a
438    /// set of named fields, which is then dispatched based on entity_type.
439    pub fn execute_insert(
440        &self,
441        raw_query: &str,
442        query: &InsertQuery,
443    ) -> RedDBResult<RuntimeQueryResult> {
444        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
445        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
446            self,
447            &query.table,
448        );
449        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
450        // CollectionContract gate (#49): single entry point for the
451        // operator's collection-level write rules. Today this is a
452        // no-op for INSERT (APPEND ONLY permits insert); routing
453        // through the gate now means future contract bits — versioned,
454        // vault-only writes — plug in once instead of per verb.
455        crate::runtime::collection_contract::CollectionContractGate::check(
456            self,
457            &query.table,
458            crate::runtime::collection_contract::MutationKind::Insert,
459        )?;
460        // ADR 0067 (#1710): catalog model inference for the unmarked
461        // bare-VALUES INSERT. `INSERT INTO c VALUES ({…})` carries no
462        // column list and no model marker (parsed as a Row insert with an
463        // empty column list); resolve the model from the catalog so an
464        // existing document collection routes to document creation and an
465        // unknown collection surfaces a didactic error. Runs before tenant
466        // injection so a rewritten document insert is tenant-scoped like
467        // the explicit `DOCUMENT` marker path.
468        let inferred_owned;
469        let query = match self.infer_unmarked_document_insert(query)? {
470            Some(rewritten) => {
471                inferred_owned = rewritten;
472                &inferred_owned
473            }
474            None => query,
475        };
476        // Phase 2.5.4 table-scoped tenancy: if the target table is
477        // tenant-scoped and the user didn't name the tenant column,
478        // auto-inject it with the thread-local `CURRENT_TENANT()`
479        // value. When the column is named explicitly we trust the
480        // caller (useful for admin tooling that writes on behalf of
481        // specific tenants). An unbound tenant on an implicit-fill
482        // path errors up front rather than producing a row the RLS
483        // policy would silently hide.
484        let augmented_owned;
485        let query = match self.maybe_inject_tenant_column(query)? {
486            Some(new_q) => {
487                augmented_owned = new_q;
488                &augmented_owned
489            }
490            None => query,
491        };
492        if query.on_conflict.is_some()
493            && !matches!(
494                query.entity_type,
495                InsertEntityType::Row
496                    | InsertEntityType::Document
497                    | InsertEntityType::Node
498                    | InsertEntityType::Edge
499            )
500        {
501            return Err(RedDBError::InvalidOperation(
502                "ON CONFLICT is supported for table rows, documents, graph nodes, and graph edges"
503                    .to_string(),
504            ));
505        }
506        self.check_insert_column_policy(query)?;
507        if let Some(ref embed_config) = query.auto_embed {
508            // Empty provider → resolve via the embeddings task pointer
509            // (ADR-0068 §5); an explicit `USING` overrides it. A modality-
510            // incapable provider fails didactically here.
511            let provider =
512                crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
513            // S3 / #711: planner-level provider gate. Runs before the
514            // local-model preflight and the API-key resolver so neither
515            // side-effect fires when policy denies.
516            crate::runtime::ai::provider_gate::enforce(self, &provider)?;
517            if matches!(provider, crate::ai::AiProvider::Local) {
518                crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
519                // Issue #682 — pre-flight the local model registry before
520                // any row write. Missing model, uninstalled artifacts,
521                // wrong task, and disabled-feature failures surface as
522                // deterministic errors that leave the target collection
523                // untouched, satisfying the "no partial writes on
524                // embedding failure" criterion for the failure modes
525                // owned by the local provider.
526                let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
527                if model_name.is_empty() {
528                    return Err(RedDBError::Query(
529                        "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
530                         the local provider does not have an implicit default model"
531                            .to_string(),
532                    ));
533                }
534                crate::runtime::ai::local_embedding::preflight_local_embedding(
535                    &self.inner.db,
536                    model_name,
537                )?;
538            }
539        }
540
541        let mut inserted_count: u64 = 0;
542        let effective_rows =
543            effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
544        if effective_rows.len() > 1
545            && matches!(
546                query.on_conflict.as_ref().map(|clause| &clause.action),
547                Some(OnConflictAction::DoUpdate { .. })
548            )
549        {
550            return Err(RedDBError::InvalidOperation(
551                "multi-row ON CONFLICT DO UPDATE is not supported atomically; execute one proposed row per statement"
552                    .to_string(),
553            ));
554        }
555
556        // Ensure the collection exists (auto-create on first insert).
557        let store = self.inner.db.store();
558        let _ = store.get_or_create_collection(&query.table);
559        let declared_model = self
560            .db()
561            .collection_contract_arc(&query.table)
562            .map(|contract| contract.declared_model);
563        if query.on_conflict.is_some()
564            && matches!(
565                declared_model,
566                Some(crate::catalog::CollectionModel::TimeSeries)
567            )
568        {
569            return Err(RedDBError::InvalidOperation(
570                "ON CONFLICT is not supported for time-series collections".to_string(),
571            ));
572        }
573
574        // One collection-scoped acquisition per upsert statement. Conflict
575        // discovery currently scans the collection, so the lock is not the
576        // dominant cost; it closes the check-then-insert race for every model
577        // until unique indexes become the atomic arbiter themselves.
578        let conflict_rmw_lock = query.on_conflict.as_ref().map(|_| {
579            self.inner
580                .rmw_locks
581                .lock_for(&query.table, "__collection_on_conflict_insert__")
582        });
583        let _conflict_rmw_guard = conflict_rmw_lock.as_ref().map(|lock| lock.lock());
584
585        let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
586            if query.returning.is_some() {
587                Some(Vec::with_capacity(effective_rows.len()))
588            } else {
589                None
590            };
591        let mut returning_result: Option<UnifiedResult> = None;
592        let mut conflict_returning_records: Vec<UnifiedRecord> = Vec::new();
593        let mut auto_embed_ids = Vec::new();
594
595        if matches!(query.entity_type, InsertEntityType::Row)
596            && !matches!(
597                declared_model,
598                Some(crate::catalog::CollectionModel::TimeSeries)
599            )
600        {
601            // Issue #523 + #524: blockchain collections seal each row into the
602            // chain. When the caller omits the reserved columns, the engine
603            // auto-fills (#523). When the caller supplies any reserved column,
604            // the values are validated against the current tip and a mismatch
605            // surfaces a `BlockchainConflict:` error mapped to HTTP 409 (#524).
606            //
607            // The whole batch runs under a per-collection chain lock so two
608            // concurrent submitters can't both bind to the same prev_hash —
609            // the loser observes the advanced tip and gets 409 with the new
610            // tip so it can retry.
611            let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
612            let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
613                Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
614            } else {
615                None
616            };
617            let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
618
619            // Issue #525 — refuse new blocks if the chain has been marked
620            // `integrity = broken` until an admin clears the flag.
621            if chain_mode && self.is_chain_integrity_broken(&query.table) {
622                return Err(RedDBError::InvalidOperation(format!(
623                    "ChainIntegrityBroken: collection '{}' is locked until \
624                     POST /collections/{}/clear-integrity-flag is called by an admin",
625                    query.table, query.table
626                )));
627            }
628
629            // Pull the tip from the in-memory cache; fall back to a one-time
630            // scan if the cache hasn't seen this collection yet (cold start
631            // after restart). Cache is updated below as rows are sealed.
632            let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
633                if chain_mode {
634                    let mut cache = self.inner.chain_tip_cache.lock();
635                    if let Some(existing) = cache.get(&query.table) {
636                        Some(existing.clone())
637                    } else if let Some(scanned) =
638                        crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
639                    {
640                        cache.insert(query.table.clone(), scanned.clone());
641                        Some(scanned)
642                    } else {
643                        None
644                    }
645                } else {
646                    None
647                };
648
649            let mut rows = Vec::with_capacity(effective_rows.len());
650            let mut accepted_row_fields: Vec<Vec<(String, Value)>> = Vec::new();
651            for row_values in &effective_rows {
652                if row_values.len() != query.columns.len() {
653                    return Err(RedDBError::Query(format!(
654                        "INSERT column count ({}) does not match value count ({})",
655                        query.columns.len(),
656                        row_values.len()
657                    )));
658                }
659                let (mut fields, mut metadata) =
660                    split_insert_metadata(self, &query.columns, row_values)?;
661                if query
662                    .on_conflict
663                    .as_ref()
664                    .is_some_and(|clause| clause.target.is_none())
665                {
666                    fields = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
667                        self,
668                        &self.db(),
669                        &query.table,
670                    )
671                    .normalize_insert_fields(fields)?;
672                }
673                if let Some(clause) = &query.on_conflict {
674                    let conflict_id = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
675                        self,
676                        &self.db(),
677                        &query.table,
678                    )
679                    .row_uniqueness_conflict_id(&fields, clause.target.as_deref())?
680                    .filter(|conflict_id| {
681                        store.get(&query.table, *conflict_id).is_some_and(|entity| {
682                            !self.snapshot_manager().is_aborted(entity.xmin)
683                        })
684                    });
685                    if let Some(conflict_id) = conflict_id {
686                        if let Some(conflicting) = store.get(&query.table, conflict_id) {
687                            let conflicting_xid = conflicting.xmin;
688                            if self.snapshot_manager().is_active(conflicting_xid)
689                                && !self.own_transaction_xids().contains(&conflicting_xid)
690                            {
691                                return Err(RedDBError::Query(format!(
692                                    "serialization conflict: ON CONFLICT key in '{}' is owned by active transaction {conflicting_xid}; retry the statement after that transaction resolves",
693                                    query.table
694                                )));
695                            }
696                        }
697                        match &clause.action {
698                            OnConflictAction::DoNothing => continue,
699                            OnConflictAction::DoUpdate { assignments } => {
700                                let assignment_exprs = assignments
701                                    .iter()
702                                    .map(|(column, expr)| {
703                                        Ok((
704                                            column.clone(),
705                                            resolve_excluded_assignment(expr, &fields)?,
706                                        ))
707                                    })
708                                    .collect::<RedDBResult<Vec<_>>>()?;
709                                let literal_assignments = assignment_exprs
710                                    .iter()
711                                    .filter_map(|(column, expr)| {
712                                        fold_expr_to_value(expr.clone())
713                                            .ok()
714                                            .map(|value| (column.clone(), value))
715                                    })
716                                    .collect();
717                                let update = UpdateQuery {
718                                    table: query.table.clone(),
719                                    target: UpdateTarget::Rows,
720                                    compound_assignment_ops: vec![None; assignment_exprs.len()],
721                                    assignment_exprs,
722                                    assignments: literal_assignments,
723                                    where_expr: None,
724                                    filter: Some(Filter::Compare {
725                                        field: FieldRef::column("", "rid"),
726                                        op: CompareOp::Eq,
727                                        // Uniqueness locates a physical version; RQL rid is
728                                        // the stable logical identity across updates.
729                                        value: Value::UnsignedInteger(
730                                            store
731                                                .get(&query.table, conflict_id)
732                                                .map(|entity| entity.logical_id().raw())
733                                                .unwrap_or(conflict_id.raw()),
734                                        ),
735                                    }),
736                                    ttl_ms: None,
737                                    expires_at_ms: None,
738                                    with_metadata: Vec::new(),
739                                    returning: query.returning.clone(),
740                                    claim_limit: None,
741                                    claim_exact: false,
742                                    order_by: Vec::new(),
743                                    limit: None,
744                                    suppress_events: query.suppress_events,
745                                };
746                                let updated = self.execute_update(raw_query, &update)?;
747                                inserted_count += updated.affected_rows;
748                                conflict_returning_records.extend(updated.result.records);
749                                continue;
750                            }
751                        }
752                    }
753                    if matches!(&clause.action, OnConflictAction::DoNothing)
754                        && crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(
755                            self,
756                            &self.db(),
757                            &query.table,
758                        )
759                        .has_row_uniqueness_conflict_with_rows(
760                            &fields,
761                            &accepted_row_fields,
762                            clause.target.as_deref(),
763                        )?
764                    {
765                        continue;
766                    }
767                }
768                if chain_mode {
769                    use crate::runtime::blockchain_kind::{
770                        chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
771                        COL_TIMESTAMP, RESERVED_COLUMNS,
772                    };
773                    let supplied_height = fields
774                        .iter()
775                        .find(|(k, _)| k == COL_BLOCK_HEIGHT)
776                        .map(|(_, v)| v.clone());
777                    let supplied_prev = fields
778                        .iter()
779                        .find(|(k, _)| k == COL_PREV_HASH)
780                        .map(|(_, v)| v.clone());
781                    let supplied_ts = fields
782                        .iter()
783                        .find(|(k, _)| k == COL_TIMESTAMP)
784                        .map(|(_, v)| v.clone());
785                    let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
786                    let user_supplied_any = supplied_height.is_some()
787                        || supplied_prev.is_some()
788                        || supplied_ts.is_some()
789                        || supplied_hash;
790
791                    fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
792                    let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
793
794                    let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
795                        Some(t) => (t.hash, t.height + 1),
796                        None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
797                    };
798                    let server_now = crate::runtime::blockchain_kind::now_ms();
799
800                    let (use_prev, use_height, use_ts) = if user_supplied_any {
801                        // Caller is participating in the chain protocol —
802                        // every field must be supplied AND match the tip.
803                        if supplied_hash {
804                            return Err(chain_conflict_error(
805                                tip_next_height.saturating_sub(1),
806                                tip_prev_hash,
807                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
808                                server_now,
809                                "hash column is engine-computed and cannot be supplied",
810                            ));
811                        }
812                        let caller_prev = match &supplied_prev {
813                            Some(Value::Blob(b)) if b.len() == 32 => {
814                                let mut a = [0u8; 32];
815                                a.copy_from_slice(b);
816                                a
817                            }
818                            Some(Value::Text(s)) if s.len() == 64 => {
819                                // Accept hex-encoded prev_hash so JSON / SQL
820                                // callers without literal-blob syntax can
821                                // still participate in the chain protocol.
822                                let mut a = [0u8; 32];
823                                let mut ok = true;
824                                for (i, slot) in a.iter_mut().enumerate() {
825                                    let pair = &s.as_ref()[i * 2..i * 2 + 2];
826                                    match u8::from_str_radix(pair, 16) {
827                                        Ok(byte) => *slot = byte,
828                                        Err(_) => {
829                                            ok = false;
830                                            break;
831                                        }
832                                    }
833                                }
834                                if !ok {
835                                    return Err(chain_conflict_error(
836                                        tip_next_height.saturating_sub(1),
837                                        tip_prev_hash,
838                                        chain_tip_full
839                                            .as_ref()
840                                            .map(|t| t.timestamp_ms)
841                                            .unwrap_or(0),
842                                        server_now,
843                                        "prev_hash is not valid hex",
844                                    ));
845                                }
846                                a
847                            }
848                            _ => {
849                                return Err(chain_conflict_error(
850                                    tip_next_height.saturating_sub(1),
851                                    tip_prev_hash,
852                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
853                                    server_now,
854                                    "prev_hash missing or not a 32-byte Blob",
855                                ));
856                            }
857                        };
858                        if caller_prev != tip_prev_hash {
859                            return Err(chain_conflict_error(
860                                tip_next_height.saturating_sub(1),
861                                tip_prev_hash,
862                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
863                                server_now,
864                                "prev_hash does not match current tip",
865                            ));
866                        }
867                        let caller_height = match &supplied_height {
868                            Some(Value::UnsignedInteger(v)) => *v,
869                            Some(Value::Integer(v)) if *v >= 0 => *v as u64,
870                            _ => {
871                                return Err(chain_conflict_error(
872                                    tip_next_height.saturating_sub(1),
873                                    tip_prev_hash,
874                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
875                                    server_now,
876                                    "block_height missing or not an unsigned integer",
877                                ));
878                            }
879                        };
880                        if caller_height != tip_next_height {
881                            return Err(chain_conflict_error(
882                                tip_next_height.saturating_sub(1),
883                                tip_prev_hash,
884                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
885                                server_now,
886                                "block_height does not match tip+1",
887                            ));
888                        }
889                        let caller_ts = match &supplied_ts {
890                            Some(Value::UnsignedInteger(v)) => *v,
891                            Some(Value::Integer(v)) if *v >= 0 => *v as u64,
892                            _ => {
893                                return Err(chain_conflict_error(
894                                    tip_next_height.saturating_sub(1),
895                                    tip_prev_hash,
896                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
897                                    server_now,
898                                    "timestamp missing or not an unsigned integer",
899                                ));
900                            }
901                        };
902                        let drift = (caller_ts as i128) - (server_now as i128);
903                        if drift.abs() > 60_000 {
904                            return Err(chain_conflict_error(
905                                tip_next_height.saturating_sub(1),
906                                tip_prev_hash,
907                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
908                                server_now,
909                                "timestamp outside ±60s of server_time",
910                            ));
911                        }
912                        (caller_prev, caller_height, caller_ts)
913                    } else {
914                        (tip_prev_hash, tip_next_height, server_now)
915                    };
916
917                    let (reserved, new_hash) =
918                        crate::runtime::blockchain_kind::make_block_reserved_fields(
919                            use_prev, use_height, use_ts, &payload,
920                        );
921                    fields.extend(reserved);
922                    chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
923                        height: use_height,
924                        hash: new_hash,
925                        timestamp_ms: use_ts,
926                    });
927                }
928                // Issue #522 — signed-writes verification. On collections
929                // created with `SIGNED_BY (...)` the row must carry valid
930                // `signer_pubkey` + `signature` reserved columns. Runs
931                // after chain_mode so canonical payload covers user-supplied
932                // fields only (blockchain reserved columns are filtered by
933                // `canonical_payload`; the two signed-writes reserved
934                // columns are split out before payload computation, then
935                // re-attached for storage). The blockchain + SIGNED_BY
936                // composition is owned by issue #526; we keep #522 to the
937                // non-chain path and let chain_mode collections punt to that
938                // slice rather than half-wire it here.
939                if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
940                    let (pk_col, sig_col, residual) =
941                        crate::runtime::signed_writes_kind::split_signature_fields(fields);
942                    let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
943                    let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
944                    crate::runtime::signed_writes_kind::verify_row(
945                        &reg,
946                        pk_col.as_ref().map(|c| c.bytes.as_slice()),
947                        sig_col.as_ref().map(|c| c.bytes.as_slice()),
948                        &payload,
949                    )
950                    .map_err(crate::runtime::signed_writes_kind::map_error)?;
951                    fields = residual;
952                    // Round-trip the reserved columns with the value
953                    // type the caller supplied (Text/hex on the SQL path,
954                    // Blob on the binary path). Keeps SELECT and WHERE
955                    // predicates symmetric with the INSERT shape.
956                    if let Some(col) = pk_col {
957                        fields.push((
958                            crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
959                            col.raw_value,
960                        ));
961                    }
962                    if let Some(col) = sig_col {
963                        fields.push((
964                            crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
965                            col.raw_value,
966                        ));
967                    }
968                }
969                merge_with_clauses(
970                    &mut metadata,
971                    query.ttl_ms,
972                    query.expires_at_ms,
973                    &query.with_metadata,
974                );
975                if let Some(snaps) = returning_snapshots.as_mut() {
976                    snaps.push(fields.clone());
977                }
978                accepted_row_fields.push(fields.clone());
979                rows.push(CreateRowInput {
980                    collection: query.table.clone(),
981                    fields,
982                    metadata,
983                    node_links: Vec::new(),
984                    vector_links: Vec::new(),
985                });
986            }
987            let batch = CreateRowsBatchInput {
988                collection: query.table.clone(),
989                rows,
990                suppress_events: query.suppress_events,
991            };
992            let outputs = if query
993                .on_conflict
994                .as_ref()
995                .is_some_and(|clause| clause.target.is_none())
996            {
997                self.create_rows_batch_prevalidated_with_outputs(batch)?
998            } else {
999                self.create_rows_batch(batch)?
1000            };
1001            inserted_count += outputs.len() as u64;
1002            if query.auto_embed.is_some() {
1003                auto_embed_ids.extend(outputs.iter().map(|output| output.id));
1004            }
1005
1006            // Chain mode: commit the new tip to the in-memory cache only after
1007            // the batch persisted successfully. If the batch threw mid-way the
1008            // cache stays on the previous tip and the chain lock releases.
1009            if chain_mode {
1010                if let Some(new_tip) = chain_tip_full.as_ref() {
1011                    self.inner
1012                        .chain_tip_cache
1013                        .lock()
1014                        .insert(query.table.clone(), new_tip.clone());
1015                }
1016            }
1017
1018            // Hypertable chunk routing: if this table was declared via
1019            // CREATE HYPERTABLE, register each row's time-column value
1020            // with the registry so chunk metadata (bounds, row counts,
1021            // TTL eligibility) stays current. This is what lets
1022            // HYPERTABLE_PRUNE_CHUNKS answer real questions + lets the
1023            // retention daemon sweep expired chunks without scanning
1024            // every row.
1025            if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1026                let time_col = &spec.time_column;
1027                // Find the column's index in the INSERT column list.
1028                if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1029                    for row in &effective_rows {
1030                        if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1031                            if *n >= 0 {
1032                                let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1033                            }
1034                        } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1035                            let _ = self.inner.db.hypertables().route(&query.table, *n);
1036                        }
1037                    }
1038                }
1039            }
1040
1041            if let (Some(items), Some(snaps)) =
1042                (query.returning.as_ref(), returning_snapshots.take())
1043            {
1044                let snaps = row_insert_returning_snapshots(&outputs, snaps);
1045                let mut result = build_returning_result(items, &snaps, Some(&outputs));
1046                result
1047                    .records
1048                    .splice(0..0, conflict_returning_records.drain(..));
1049                returning_result = Some(result);
1050            }
1051        } else {
1052            // Issue #419: surface the inserted entity id on every INSERT path.
1053            // For Node/Edge/Vector/Document/Kv we now keep each CreateEntityOutput
1054            // so a RETURNING clause (and the unconditional inserted_ids list,
1055            // below) can expose the engine-assigned id. TimeSeries (the row
1056            // branch in this else) still returns the not-supported error
1057            // because create_timeseries_point isn't plumbed through this fn.
1058            let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1059                Vec::with_capacity(effective_rows.len());
1060            let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1061            {
1062                Vec::with_capacity(effective_rows.len())
1063            } else {
1064                Vec::new()
1065            };
1066            if matches!(
1067                query.entity_type,
1068                InsertEntityType::Node | InsertEntityType::Edge
1069            ) {
1070                enum PreparedGraphInsert {
1071                    Node {
1072                        fields: Vec<(String, Value)>,
1073                        input: CreateNodeInput,
1074                    },
1075                    Edge {
1076                        fields: Vec<(String, Value)>,
1077                        input: CreateEdgeInput,
1078                    },
1079                }
1080
1081                let mut prepared = Vec::with_capacity(effective_rows.len());
1082                let mut accepted_graph_fields: Vec<Vec<(String, Value)>> = Vec::new();
1083                for row_values in &effective_rows {
1084                    if row_values.len() != query.columns.len() {
1085                        return Err(RedDBError::Query(format!(
1086                            "INSERT column count ({}) does not match value count ({})",
1087                            query.columns.len(),
1088                            row_values.len()
1089                        )));
1090                    }
1091
1092                    match query.entity_type {
1093                        InsertEntityType::Node => {
1094                            let (node_values, mut metadata) =
1095                                split_insert_metadata(self, &query.columns, row_values)?;
1096                            if let Some(clause) = &query.on_conflict {
1097                                if let Some(conflict_id) = self
1098                                    .on_conflict_unique_index_conflict_id(
1099                                        &query.table,
1100                                        &node_values,
1101                                        clause.target.as_deref(),
1102                                    )?
1103                                {
1104                                    match &clause.action {
1105                                        OnConflictAction::DoNothing => continue,
1106                                        OnConflictAction::DoUpdate { assignments } => {
1107                                            let updated = self
1108                                                .execute_unique_index_conflict_update(
1109                                                    raw_query,
1110                                                    query,
1111                                                    conflict_id,
1112                                                    UpdateTarget::Nodes,
1113                                                    &node_values,
1114                                                    assignments,
1115                                                )?;
1116                                            inserted_count += updated.affected_rows;
1117                                            conflict_returning_records
1118                                                .extend(updated.result.records);
1119                                            continue;
1120                                        }
1121                                    }
1122                                }
1123                                if matches!(&clause.action, OnConflictAction::DoNothing)
1124                                    && self.has_on_conflict_unique_index_conflict_with_fields(
1125                                        &query.table,
1126                                        &node_values,
1127                                        &accepted_graph_fields,
1128                                        clause.target.as_deref(),
1129                                    )?
1130                                {
1131                                    continue;
1132                                }
1133                            }
1134                            merge_with_clauses(
1135                                &mut metadata,
1136                                query.ttl_ms,
1137                                query.expires_at_ms,
1138                                &query.with_metadata,
1139                            );
1140                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1141                            apply_collection_default_ttl_metadata(
1142                                self,
1143                                &query.table,
1144                                &mut metadata,
1145                            );
1146                            let (columns, values) = pairwise_columns_values(&node_values);
1147                            let label = find_column_value_string(&columns, &values, "label")?;
1148                            let node_type =
1149                                find_column_value_opt_string(&columns, &values, "node_type");
1150                            let properties = extract_remaining_properties(
1151                                &columns,
1152                                &values,
1153                                &["label", "node_type"],
1154                            );
1155                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1156                                properties.iter().map(|(key, _)| key.as_str()),
1157                                &format!("node '{}'", query.table),
1158                            )?;
1159                            accepted_graph_fields.push(node_values.clone());
1160                            prepared.push(PreparedGraphInsert::Node {
1161                                fields: node_values,
1162                                input: CreateNodeInput {
1163                                    collection: query.table.clone(),
1164                                    label,
1165                                    node_type,
1166                                    properties,
1167                                    metadata,
1168                                    embeddings: Vec::new(),
1169                                    table_links: Vec::new(),
1170                                    node_links: Vec::new(),
1171                                },
1172                            });
1173                        }
1174                        InsertEntityType::Edge => {
1175                            let (edge_values, mut metadata) =
1176                                split_insert_metadata(self, &query.columns, row_values)?;
1177                            if let Some(clause) = &query.on_conflict {
1178                                if let Some(conflict_id) = self
1179                                    .on_conflict_unique_index_conflict_id(
1180                                        &query.table,
1181                                        &edge_values,
1182                                        clause.target.as_deref(),
1183                                    )?
1184                                {
1185                                    match &clause.action {
1186                                        OnConflictAction::DoNothing => continue,
1187                                        OnConflictAction::DoUpdate { assignments } => {
1188                                            let updated = self
1189                                                .execute_unique_index_conflict_update(
1190                                                    raw_query,
1191                                                    query,
1192                                                    conflict_id,
1193                                                    UpdateTarget::Edges,
1194                                                    &edge_values,
1195                                                    assignments,
1196                                                )?;
1197                                            inserted_count += updated.affected_rows;
1198                                            conflict_returning_records
1199                                                .extend(updated.result.records);
1200                                            continue;
1201                                        }
1202                                    }
1203                                }
1204                                if matches!(&clause.action, OnConflictAction::DoNothing)
1205                                    && self.has_on_conflict_unique_index_conflict_with_fields(
1206                                        &query.table,
1207                                        &edge_values,
1208                                        &accepted_graph_fields,
1209                                        clause.target.as_deref(),
1210                                    )?
1211                                {
1212                                    continue;
1213                                }
1214                            }
1215                            merge_with_clauses(
1216                                &mut metadata,
1217                                query.ttl_ms,
1218                                query.expires_at_ms,
1219                                &query.with_metadata,
1220                            );
1221                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1222                            apply_collection_default_ttl_metadata(
1223                                self,
1224                                &query.table,
1225                                &mut metadata,
1226                            );
1227                            let (columns, values) = pairwise_columns_values(&edge_values);
1228                            let label = find_column_value_string(&columns, &values, "label")?;
1229                            ensure_non_tree_structural_edge_label(&label)?;
1230                            let from_id = resolve_edge_endpoint_any(
1231                                self.inner.db.store().as_ref(),
1232                                &query.table,
1233                                &columns,
1234                                &values,
1235                                &["from_rid", "from"],
1236                            )?;
1237                            let to_id = resolve_edge_endpoint_any(
1238                                self.inner.db.store().as_ref(),
1239                                &query.table,
1240                                &columns,
1241                                &values,
1242                                &["to_rid", "to"],
1243                            )?;
1244                            let weight = find_column_value_f32_opt(&columns, &values, "weight");
1245                            let properties = extract_remaining_properties(
1246                                &columns,
1247                                &values,
1248                                &["label", "from_rid", "to_rid", "from", "to", "weight"],
1249                            );
1250                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1251                                properties.iter().map(|(key, _)| key.as_str()),
1252                                &format!("edge '{}'", query.table),
1253                            )?;
1254                            accepted_graph_fields.push(edge_values.clone());
1255                            prepared.push(PreparedGraphInsert::Edge {
1256                                fields: edge_values,
1257                                input: CreateEdgeInput {
1258                                    collection: query.table.clone(),
1259                                    label,
1260                                    from: EntityId::new(from_id),
1261                                    to: EntityId::new(to_id),
1262                                    weight,
1263                                    properties,
1264                                    metadata,
1265                                },
1266                            });
1267                        }
1268                        _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1269                    }
1270                }
1271
1272                ensure_graph_insert_contract(self, &query.table)?;
1273                let mut batch = self.inner.db.batch();
1274                let mut graph_index_fields: Vec<Vec<(String, Value)>> =
1275                    Vec::with_capacity(prepared.len());
1276                for item in prepared {
1277                    match item {
1278                        PreparedGraphInsert::Node { fields, input } => {
1279                            if query.returning.is_some() {
1280                                returning_field_snaps.push(fields.clone());
1281                            }
1282                            graph_index_fields.push(fields);
1283                            let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1284                            batch = batch.add_node_with_type(
1285                                input.collection,
1286                                input.label,
1287                                node_type,
1288                                input.properties.into_iter().collect(),
1289                                input.metadata.into_iter().collect(),
1290                            );
1291                        }
1292                        PreparedGraphInsert::Edge { fields, input } => {
1293                            if query.returning.is_some() {
1294                                returning_field_snaps.push(fields.clone());
1295                            }
1296                            graph_index_fields.push(fields);
1297                            batch = batch.add_edge(
1298                                input.collection,
1299                                input.label,
1300                                input.from,
1301                                input.to,
1302                                input.weight.unwrap_or(1.0),
1303                                input.properties.into_iter().collect(),
1304                                input.metadata.into_iter().collect(),
1305                            );
1306                        }
1307                    }
1308                }
1309                let batch_result = batch
1310                    .execute()
1311                    .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1312                let (ids, entity_kind) = match query.entity_type {
1313                    InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1314                    InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1315                    _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1316                };
1317                for id in &ids {
1318                    self.stamp_xmin_if_in_txn(&query.table, *id);
1319                }
1320                if !graph_index_fields.is_empty() {
1321                    let index_rows: Vec<_> = ids
1322                        .iter()
1323                        .zip(graph_index_fields)
1324                        .map(|(id, fields)| (*id, fields))
1325                        .collect();
1326                    self.inner
1327                        .index_store
1328                        .index_entity_insert_batch(&query.table, &index_rows)
1329                        .map_err(RedDBError::Internal)?;
1330                }
1331                if query.returning.is_some() {
1332                    returning_field_snaps = graph_insert_returning_snapshots(
1333                        self.inner.db.store().as_ref(),
1334                        &query.table,
1335                        &ids,
1336                    );
1337                }
1338                self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1339                let store = self.inner.db.store();
1340                entity_outputs.extend(ids.iter().map(|id| {
1341                    crate::application::entity::CreateEntityOutput {
1342                        id: *id,
1343                        entity: store.get(&query.table, *id),
1344                    }
1345                }));
1346                inserted_count += ids.len() as u64;
1347            } else {
1348                for row_values in &effective_rows {
1349                    if row_values.len() != query.columns.len() {
1350                        return Err(RedDBError::Query(format!(
1351                            "INSERT column count ({}) does not match value count ({})",
1352                            query.columns.len(),
1353                            row_values.len()
1354                        )));
1355                    }
1356
1357                    match query.entity_type {
1358                        InsertEntityType::Row => {
1359                            if query.returning.is_some() {
1360                                return Err(RedDBError::Query(
1361                                "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1362                                    .to_string(),
1363                            ));
1364                            }
1365                            let (fields, mut metadata) =
1366                                split_insert_metadata(self, &query.columns, row_values)?;
1367                            merge_with_clauses(
1368                                &mut metadata,
1369                                query.ttl_ms,
1370                                query.expires_at_ms,
1371                                &query.with_metadata,
1372                            );
1373                            self.insert_timeseries_point(&query.table, fields, metadata)?;
1374                        }
1375                        InsertEntityType::Node | InsertEntityType::Edge => {
1376                            unreachable!("NODE and EDGE are handled by the prepared graph path")
1377                        }
1378                        InsertEntityType::Vector => {
1379                            let (vector_values, mut metadata) =
1380                                split_insert_metadata(self, &query.columns, row_values)?;
1381                            merge_with_clauses(
1382                                &mut metadata,
1383                                query.ttl_ms,
1384                                query.expires_at_ms,
1385                                &query.with_metadata,
1386                            );
1387                            let (columns, values) = pairwise_columns_values(&vector_values);
1388                            let dense = find_column_value_vec_f32_any(
1389                                &columns,
1390                                &values,
1391                                &["dense", "embedding"],
1392                            )?;
1393                            merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1394                            let content =
1395                                find_column_value_opt_string(&columns, &values, "content");
1396                            if query.returning.is_some() {
1397                                returning_field_snaps.push(vector_values.clone());
1398                            }
1399                            let input = CreateVectorInput {
1400                                collection: query.table.clone(),
1401                                dense,
1402                                content,
1403                                metadata,
1404                                link_row: None,
1405                                link_node: None,
1406                            };
1407                            entity_outputs.push(self.create_vector(input)?);
1408                        }
1409                        InsertEntityType::Document => {
1410                            let (document_values, mut metadata) =
1411                                split_insert_metadata(self, &query.columns, row_values)?;
1412                            let (columns, values) = pairwise_columns_values(&document_values);
1413                            let body = find_document_body_json(&columns, &values)?;
1414                            let conflict_fields =
1415                                document_on_conflict_fields(&document_values, &body)?;
1416                            if let Some(clause) = &query.on_conflict {
1417                                if let Some(conflict_id) = self
1418                                    .on_conflict_unique_index_conflict_id(
1419                                        &query.table,
1420                                        &conflict_fields,
1421                                        clause.target.as_deref(),
1422                                    )?
1423                                {
1424                                    match &clause.action {
1425                                        OnConflictAction::DoNothing => continue,
1426                                        OnConflictAction::DoUpdate { assignments } => {
1427                                            let updated = self
1428                                                .execute_unique_index_conflict_update(
1429                                                    raw_query,
1430                                                    query,
1431                                                    conflict_id,
1432                                                    UpdateTarget::Documents,
1433                                                    &conflict_fields,
1434                                                    assignments,
1435                                                )?;
1436                                            inserted_count += updated.affected_rows;
1437                                            conflict_returning_records
1438                                                .extend(updated.result.records);
1439                                            continue;
1440                                        }
1441                                    }
1442                                }
1443                            }
1444                            merge_with_clauses(
1445                                &mut metadata,
1446                                query.ttl_ms,
1447                                query.expires_at_ms,
1448                                &query.with_metadata,
1449                            );
1450                            let input = CreateDocumentInput {
1451                                collection: query.table.clone(),
1452                                body,
1453                                metadata,
1454                                node_links: Vec::new(),
1455                                vector_links: Vec::new(),
1456                            };
1457                            let output = self.create_document(input)?;
1458                            if query.returning.is_some() {
1459                                let fields = output
1460                                    .entity
1461                                    .as_ref()
1462                                    .map(entity_row_fields_snapshot)
1463                                    .filter(|fields| !fields.is_empty())
1464                                    .unwrap_or(document_values);
1465                                returning_field_snaps.push(fields);
1466                            }
1467                            entity_outputs.push(output);
1468                        }
1469                        InsertEntityType::Kv => {
1470                            let (kv_values, mut metadata) =
1471                                split_insert_metadata(self, &query.columns, row_values)?;
1472                            merge_with_clauses(
1473                                &mut metadata,
1474                                query.ttl_ms,
1475                                query.expires_at_ms,
1476                                &query.with_metadata,
1477                            );
1478                            let (columns, values) = pairwise_columns_values(&kv_values);
1479                            let key = find_column_value_string(&columns, &values, "key")?;
1480                            let value = find_column_value(&columns, &values, "value")?;
1481                            if query.returning.is_some() {
1482                                returning_field_snaps.push(kv_values.clone());
1483                            }
1484                            let input = CreateKvInput {
1485                                collection: query.table.clone(),
1486                                key,
1487                                value,
1488                                metadata,
1489                            };
1490                            entity_outputs.push(self.create_kv(input)?);
1491                        }
1492                    }
1493
1494                    inserted_count += 1;
1495                }
1496            }
1497
1498            if query.auto_embed.is_some() {
1499                auto_embed_ids.extend(entity_outputs.iter().map(|output| output.id));
1500            }
1501
1502            if let Some(items) = query.returning.as_ref() {
1503                let mut result =
1504                    build_returning_result(items, &returning_field_snaps, Some(&entity_outputs));
1505                result
1506                    .records
1507                    .splice(0..0, conflict_returning_records.drain(..));
1508                returning_result = Some(result);
1509            }
1510        }
1511
1512        // Auto-embed pipeline: batch-embed fields across all inserted rows via AiBatchClient.
1513        if let Some(ref embed_config) = query.auto_embed {
1514            let store = self.inner.db.store();
1515            let provider =
1516                crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
1517            let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1518            // Local provider runs in-process — no API key path applies.
1519            // The pre-flight above already required `MODEL '<name>'`
1520            // for the local case, so the unwrap_or default below only
1521            // ever fires for OpenAI-compatible providers.
1522            let api_key = if is_local_provider {
1523                String::new()
1524            } else {
1525                crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
1526            };
1527            // Explicit `MODEL '<name>'` wins; otherwise resolve via the
1528            // provider models block then the built-in default (ADR-0068 §5).
1529            let model = crate::ai::resolve_embeddings_model_from_runtime(
1530                self,
1531                &provider,
1532                embed_config.model.as_deref(),
1533            );
1534
1535            // Read only this INSERT's returned IDs. A pre-write statement
1536            // snapshot correctly excludes newly committed rows; a fresh/global
1537            // scan could instead pick another writer's rows or conflict skips.
1538            let recent: Vec<_> = auto_embed_ids
1539                .iter()
1540                .filter_map(|id| store.get(&query.table, *id))
1541                .collect();
1542
1543            // Collector phase: (entity_index, combined_text) for rows that have non-empty fields.
1544            let entity_combos: Vec<(usize, String)> = recent
1545                .iter()
1546                .enumerate()
1547                .filter_map(|(i, entity)| {
1548                    if let EntityData::Row(ref row) = entity.data {
1549                        let texts: Vec<String> = embed_config
1550                            .fields
1551                            .iter()
1552                            .filter_map(|field| match row.get_field(field) {
1553                                Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1554                                _ => None,
1555                            })
1556                            .collect();
1557                        if !texts.is_empty() {
1558                            return Some((i, texts.join(" ")));
1559                        }
1560                    }
1561                    None
1562                })
1563                .collect();
1564
1565            if !entity_combos.is_empty() {
1566                // Batch phase: single provider round-trip for all rows.
1567                let batch_texts: Vec<String> =
1568                    entity_combos.iter().map(|(_, t)| t.clone()).collect();
1569
1570                // Issue #682 — when the provider is `local`, bypass
1571                // AiBatchClient (which is HTTP-only) and dispatch
1572                // directly through the in-process local embedding
1573                // backend. All texts go in one call, mirroring the
1574                // single-round-trip shape of the remote path. The
1575                // local backend does not perform intra-batch dedup —
1576                // each input position gets its own row in the output
1577                // — which keeps the per-row "create_vector" loop
1578                // below correct without additional fan-out logic.
1579                let embeddings = if is_local_provider {
1580                    let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1581                        &self.inner.db,
1582                        &model,
1583                        batch_texts,
1584                    )?;
1585                    response.embeddings
1586                } else {
1587                    let batch_client =
1588                        crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1589
1590                    match tokio::runtime::Handle::try_current() {
1591                        Ok(handle) => tokio::task::block_in_place(|| {
1592                            handle.block_on(batch_client.embed_batch(
1593                                &provider,
1594                                &model,
1595                                &api_key,
1596                                batch_texts,
1597                            ))
1598                        }),
1599                        Err(_) => {
1600                            return Err(RedDBError::Query(
1601                                "AUTO EMBED requires a Tokio runtime context".to_string(),
1602                            ));
1603                        }
1604                    }
1605                    .map_err(|e| RedDBError::Query(e.to_string()))?
1606                };
1607
1608                // Distribute phase: persist one vector per non-empty embedding.
1609                for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1610                    if dense.is_empty() {
1611                        continue;
1612                    }
1613                    self.create_vector(CreateVectorInput {
1614                        collection: query.table.clone(),
1615                        dense,
1616                        content: Some(combined.clone()),
1617                        metadata: Vec::new(),
1618                        link_row: None,
1619                        link_node: None,
1620                    })?;
1621                }
1622            }
1623        }
1624
1625        if inserted_count > 0 {
1626            self.note_table_write(&query.table);
1627        }
1628
1629        let mut result = RuntimeQueryResult::dml_result(
1630            raw_query.to_string(),
1631            inserted_count,
1632            "insert",
1633            "runtime-dml",
1634        );
1635        if let Some(returning) = returning_result {
1636            result.result = returning;
1637        }
1638        Ok(result)
1639    }
1640
1641    fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1642        let Some(auth_store) = self.inner.auth_store.read().clone() else {
1643            return Ok(());
1644        };
1645        if !auth_store.iam_authorization_enabled() {
1646            return Ok(());
1647        }
1648        let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1649            return Ok(());
1650        };
1651
1652        let tenant = crate::runtime::impl_core::current_tenant();
1653        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1654        let request = crate::auth::ColumnAccessRequest {
1655            action: "insert".to_string(),
1656            schema: None,
1657            table: query.table.clone(),
1658            columns: query.columns.clone(),
1659        };
1660        let ctx = crate::auth::policies::EvalContext {
1661            principal_tenant: tenant.clone(),
1662            current_tenant: tenant,
1663            peer_ip: None,
1664            mfa_present: false,
1665            now_ms: crate::auth::now_ms(),
1666            principal_is_admin_role: role == crate::auth::Role::Admin,
1667            principal_is_platform_scoped: principal.tenant.is_none(),
1668        };
1669
1670        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1671        let table_allowed = matches!(
1672            outcome.table_decision,
1673            crate::auth::policies::Decision::Allow { .. }
1674                | crate::auth::policies::Decision::AdminBypass
1675        );
1676        if !table_allowed {
1677            return Err(RedDBError::Query(format!(
1678                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1679                outcome.table_resource.kind, outcome.table_resource.name
1680            )));
1681        }
1682        if let Some(denied) = outcome.first_denied_column() {
1683            return Err(RedDBError::Query(format!(
1684                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1685                denied.resource.kind, denied.resource.name
1686            )));
1687        }
1688
1689        Ok(())
1690    }
1691
1692    pub(crate) fn insert_timeseries_point(
1693        &self,
1694        collection: &str,
1695        fields: Vec<(String, Value)>,
1696        mut metadata: Vec<(String, MetadataValue)>,
1697    ) -> RedDBResult<EntityId> {
1698        apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1699
1700        let (columns, values) = pairwise_columns_values(&fields);
1701        validate_timeseries_insert_columns(&columns)?;
1702
1703        // Issue #577 — AnalyticsSchemaRegistry hook. If the row carries
1704        // an `event_name` whose schema is registered, validate the
1705        // `payload` JSON against it BEFORE any write side-effect. On
1706        // failure we return a typed error and the row is not
1707        // persisted. When no schema is registered for the event name
1708        // (or no `event_name` column is supplied at all) we fall
1709        // through to the normal write path for back-compat with
1710        // existing timeseries rows.
1711        let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1712        let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1713        if let Some(event_name) = event_name_opt.as_deref() {
1714            let store_for_schema = self.inner.db.store();
1715            if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1716                .is_some()
1717            {
1718                let payload_json = payload_opt.as_deref().unwrap_or("{}");
1719                super::analytics_schema_registry::validate(
1720                    store_for_schema.as_ref(),
1721                    event_name,
1722                    payload_json,
1723                )
1724                .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1725            }
1726        }
1727
1728        // `metric` is required by the existing timeseries write path;
1729        // when an analytics-style row supplies `event_name` but not
1730        // `metric`, fall back to the event name so the storage path
1731        // still has a non-empty metric tag.
1732        let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1733            Some(m) => m,
1734            None => event_name_opt.clone().ok_or_else(|| {
1735                RedDBError::Query(
1736                    "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1737                )
1738            })?,
1739        };
1740        // `value` is optional for analytics-event rows (which are
1741        // semantically counts of 1); default to 1.0 when missing so
1742        // analytics inserts don't have to fabricate a metric value.
1743        let value = match find_column_value_opt_string(&columns, &values, "value") {
1744            Some(s) => s.parse::<f64>().unwrap_or(1.0),
1745            None => columns
1746                .iter()
1747                .position(|c| c.eq_ignore_ascii_case("value"))
1748                .and_then(|i| match &values[i] {
1749                    Value::Float(f) => Some(*f),
1750                    Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1751                    Value::UnsignedInteger(n) => Some(*n as f64),
1752                    _ => None,
1753                })
1754                .unwrap_or(1.0),
1755        };
1756        let timestamp_ns =
1757            find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1758        let mut tags = find_timeseries_tags(&columns, &values)?;
1759        if let Some(ref name) = event_name_opt {
1760            tags.entry("event_name".to_string())
1761                .or_insert_with(|| name.clone());
1762        }
1763        if let Some(ref payload) = payload_opt {
1764            tags.entry("payload".to_string())
1765                .or_insert_with(|| payload.clone());
1766        }
1767        let fields = extract_remaining_properties(
1768            &columns,
1769            &values,
1770            &[
1771                "metric",
1772                "value",
1773                "tags",
1774                "timestamp",
1775                "timestamp_ns",
1776                "time",
1777                "event_name",
1778                "payload",
1779            ],
1780        );
1781        let index_fields = fields.clone();
1782        let _segment_reservation = self.admit_non_evictable_growth(
1783            crate::storage::memory_pools::MemoryPool::SegmentArena,
1784            &format!("insert into {collection}"),
1785            crate::runtime::memory_admission::estimate_timeseries_point_growth(
1786                &metric, &tags, &fields,
1787            ),
1788        )?;
1789        let indexed_columns = self
1790            .inner
1791            .index_store
1792            .list_indices(collection)
1793            .into_iter()
1794            .flat_map(|index| index.columns)
1795            .collect::<Vec<_>>();
1796        let _index_reservation = if indexed_columns.is_empty() {
1797            None
1798        } else {
1799            Some(self.admit_non_evictable_growth(
1800                crate::storage::memory_pools::MemoryPool::IndexMemory,
1801                &format!("index insert into {collection}"),
1802                crate::runtime::memory_admission::estimate_index_growth(
1803                    std::slice::from_ref(&index_fields),
1804                    &indexed_columns,
1805                ),
1806            )?)
1807        };
1808        let series_id = super::impl_timeseries::intern_timeseries_series(
1809            self.inner.db.store().as_ref(),
1810            collection,
1811            &metric,
1812            &tags,
1813        )?;
1814
1815        let mut entity = UnifiedEntity::new(
1816            EntityId::new(0),
1817            EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1818                series: collection.to_string(),
1819                metric: metric.clone(),
1820            })),
1821            EntityData::TimeSeries(crate::storage::TimeSeriesData {
1822                metric,
1823                series_id: Some(series_id),
1824                timestamp_ns,
1825                value,
1826                tags: HashMap::new(),
1827                fields: fields.into_iter().collect(),
1828            }),
1829        );
1830        // MVCC #30: stamp xmin with the active tx xid (inside a tx)
1831        // or an autocommit xid (allocated and committed up-front so
1832        // future snapshots see the row as soon as it lands).
1833        let writer_xid = match self.current_xid() {
1834            Some(xid) => xid,
1835            None => {
1836                let mgr = self.snapshot_manager();
1837                let xid = mgr.begin();
1838                mgr.commit(xid);
1839                xid
1840            }
1841        };
1842        entity.set_xmin(writer_xid);
1843
1844        let store = self.inner.db.store();
1845        let id = store
1846            .insert_auto(collection, entity)
1847            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1848        self.inner
1849            .index_store
1850            .index_entity_insert(collection, id, &index_fields)
1851            .map_err(RedDBError::Internal)?;
1852
1853        if !metadata.is_empty() {
1854            let _ = store.set_metadata(
1855                collection,
1856                id,
1857                Metadata::with_fields(metadata.into_iter().collect()),
1858            );
1859        }
1860
1861        let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1862
1863        self.cdc_emit(
1864            crate::replication::cdc::ChangeOperation::Insert,
1865            collection,
1866            id.raw(),
1867            "timeseries",
1868        );
1869
1870        Ok(id)
1871    }
1872
1873    /// Execute UPDATE table SET col=val, ... WHERE filter
1874    ///
1875    /// Scans the target collection, evaluates the WHERE filter against each
1876    /// record, and patches every matching entity.
1877    pub fn execute_update(
1878        &self,
1879        raw_query: &str,
1880        query: &UpdateQuery,
1881    ) -> RedDBResult<RuntimeQueryResult> {
1882        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1883        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
1884            self,
1885            &query.table,
1886        );
1887        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
1888        // Issue #523 — blockchain collections are immutable. Reject before
1889        // RLS / RETURNING work so the operator sees a clean 409-mapped
1890        // error instead of a partially-applied mutation surface.
1891        if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1892            return Err(RedDBError::InvalidOperation(format!(
1893                "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1894                query.table
1895            )));
1896        }
1897        // Queue-shaped CLAIM (ADR 0020, #1609): a CLAIM on a queue collection
1898        // is a QueueLifecycle delivery acquisition, not a raw row UPDATE.
1899        // Route it through the lifecycle seam before the table-shaped
1900        // contract / RLS gates below (which would reject an UPDATE against a
1901        // queue's declared model) so QueueLifecycle stays the sole authority
1902        // for delivery state.
1903        if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1904            return self.execute_queue_shaped_claim(raw_query, query);
1905        }
1906        // ADR 0067 (#1711): the DOCUMENTS / ROWS / KV UPDATE markers were
1907        // removed. Resolve the model of an unmarked UPDATE from the catalog so
1908        // a document collection routes to document semantics and a KV
1909        // collection keeps its key-immutability guard, and gate dotted SET
1910        // targets that only a document body can satisfy. Runs before the
1911        // contract / RLS gates so every downstream path — the inner scan,
1912        // RETURNING — observes the resolved target.
1913        let inferred_owned;
1914        let query = match self.resolve_unmarked_update_target(query)? {
1915            Some(rewritten) => {
1916                inferred_owned = rewritten;
1917                &inferred_owned
1918            }
1919            None => query,
1920        };
1921        // CollectionContract gate (#50): runs the APPEND ONLY guard
1922        // (and any future contract bits) before RLS / RETURNING work
1923        // so the operator's immutability declaration is honoured
1924        // uniformly and the error message points at the DDL rather
1925        // than at a downstream symptom.
1926        crate::runtime::collection_contract::CollectionContractGate::check(
1927            self,
1928            &query.table,
1929            crate::runtime::collection_contract::MutationKind::Update,
1930        )?;
1931        ensure_update_target_contract(self, &query.table, query.target)?;
1932        ensure_kv_key_update_target_allowed(query)?;
1933        ensure_graph_identity_update_target_allowed(query)?;
1934
1935        // Apply RLS augmentation first so every downstream path — plain
1936        // UPDATE, UPDATE...RETURNING, the inner scan — observes the
1937        // same policy-filtered target set. This prevents RETURNING
1938        // from ever exposing rows the UPDATE policy would have
1939        // denied.
1940        let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1941        let augmented_query: UpdateQuery;
1942        let effective_query: &UpdateQuery = if rls_gated {
1943            let update_filter = crate::runtime::impl_core::rls_policy_filter(
1944                self,
1945                &query.table,
1946                reddb_rql::ast::PolicyAction::Update,
1947            );
1948            let Some(mut policy) = update_filter else {
1949                // No admitting policy: zero rows affected, empty
1950                // RETURNING (never leak rows the caller can't touch).
1951                let mut response = RuntimeQueryResult::dml_result(
1952                    raw_query.to_string(),
1953                    0,
1954                    "update",
1955                    "runtime-dml-rls",
1956                );
1957                if let Some(items) = query.returning.clone() {
1958                    response.result = build_returning_result(&items, &[], None);
1959                }
1960                return Ok(response);
1961            };
1962            if query.claim_limit.is_some() {
1963                let read_filter = crate::runtime::impl_core::rls_policy_filter(
1964                    self,
1965                    &query.table,
1966                    reddb_rql::ast::PolicyAction::Select,
1967                );
1968                let Some(read_policy) = read_filter else {
1969                    let mut response = RuntimeQueryResult::dml_result(
1970                        raw_query.to_string(),
1971                        0,
1972                        "update",
1973                        "runtime-dml-rls",
1974                    );
1975                    if let Some(items) = query.returning.clone() {
1976                        response.result = build_returning_result(&items, &[], None);
1977                    }
1978                    return Ok(response);
1979                };
1980                policy = reddb_rql::ast::Filter::And(Box::new(read_policy), Box::new(policy));
1981            }
1982            let mut augmented = query.clone();
1983            augmented.filter = Some(match augmented.filter.take() {
1984                Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
1985                None => policy,
1986            });
1987            augmented_query = augmented;
1988            &augmented_query
1989        } else {
1990            query
1991        };
1992
1993        // RETURNING wraps the inner executor and uses the touched-id
1994        // list the inner reports so the post-image reflects exactly
1995        // the rows the UPDATE actually mutated (not whatever a
1996        // separate SELECT might have observed).
1997        if let Some(items) = effective_query.returning.clone() {
1998            let mut inner_query = effective_query.clone();
1999            inner_query.returning = None;
2000            let (mut response, touched_ids) =
2001                self.execute_update_inner_tracked(raw_query, &inner_query)?;
2002
2003            let mut snapshots = if matches!(
2004                effective_query.target,
2005                UpdateTarget::Nodes | UpdateTarget::Edges
2006            ) {
2007                graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
2008            } else {
2009                super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
2010                    .row_snapshots(&touched_ids)
2011            };
2012            if matches!(effective_query.target, UpdateTarget::Kv) {
2013                restore_kv_returning_keys(
2014                    self,
2015                    &effective_query.table,
2016                    &touched_ids,
2017                    &mut snapshots,
2018                );
2019            }
2020
2021            response.result = build_returning_result(&items, &snapshots, None);
2022            response.engine = "runtime-dml-returning";
2023            return Ok(response);
2024        }
2025
2026        self.execute_update_inner(raw_query, effective_query)
2027    }
2028
2029    /// Back-compat shim: the older entry point ignored touched ids.
2030    fn execute_update_inner(
2031        &self,
2032        raw_query: &str,
2033        query: &UpdateQuery,
2034    ) -> RedDBResult<RuntimeQueryResult> {
2035        self.execute_update_inner_tracked(raw_query, query)
2036            .map(|(res, _)| res)
2037    }
2038
2039    /// Enforce the concurrent-claim ORDER BY index-gate (ADR 0063, #1607).
2040    ///
2041    /// A `CLAIM LIMIT n` / `CLAIM EXACT n` on a logical-identity model (tables
2042    /// and documents) must order its candidates through a compatible index; the
2043    /// planner rejects an ordering no index on the collection can serve rather
2044    /// than falling back to a broad write-path sort. KV (key identity) and graph
2045    /// nodes/edges are exempt — their claim identity is intrinsic, not a user
2046    /// ORDER BY column that needs a secondary index.
2047    fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
2048        if query.claim_limit.is_none()
2049            || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
2050        {
2051            return Ok(());
2052        }
2053        let available_indexes: Vec<Vec<String>> = self
2054            .index_store_ref()
2055            .list_indices(&query.table)
2056            .into_iter()
2057            .map(|index| index.columns)
2058            .collect();
2059        reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
2060            .map_err(RedDBError::InvalidOperation)
2061    }
2062
2063    fn execute_update_inner_tracked(
2064        &self,
2065        raw_query: &str,
2066        query: &UpdateQuery,
2067    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2068        self.enforce_claim_order_by_index_gate(query)?;
2069        let store = self.inner.db.store();
2070        let topology_lock = self
2071            .index_store_ref()
2072            .collection_topology_lock(&query.table);
2073        let effective_filter = effective_update_filter(query);
2074        let compiled_plan = self.compile_update_plan(query)?;
2075        let needs_rmw_lock = update_needs_rmw_lock(query);
2076        let claim_model = update_model_name(update_target_model(query.target));
2077        if query.claim_limit.is_some() {
2078            self.inner
2079                .claim_telemetry
2080                .record_attempt(&query.table, claim_model);
2081        }
2082        let claim_lock = query.claim_limit.map(|_| {
2083            self.inner
2084                .rmw_locks
2085                .lock_for(&query.table, "__table_claim_update__")
2086        });
2087        let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
2088            let Some(guard) = lock.try_lock() else {
2089                let skipped_locked = query.claim_limit.unwrap_or(1);
2090                self.inner.claim_telemetry.record_skipped_locked(
2091                    &query.table,
2092                    claim_model,
2093                    skipped_locked,
2094                );
2095                tracing::debug!(
2096                    target: "reddb::claim",
2097                    collection = %query.table,
2098                    model = claim_model,
2099                    skipped_locked,
2100                    "concurrent claim skipped locked candidates"
2101                );
2102                return Ok((
2103                    RuntimeQueryResult::dml_result(
2104                        raw_query.to_string(),
2105                        0,
2106                        "update",
2107                        "runtime-dml",
2108                    ),
2109                    Vec::new(),
2110                ));
2111            };
2112            Some(guard)
2113        } else {
2114            None
2115        };
2116        let table_rmw_lock = if needs_rmw_lock {
2117            Some(
2118                self.inner
2119                    .rmw_locks
2120                    .lock_for(&query.table, "__table_rmw_update__"),
2121            )
2122        } else {
2123            None
2124        };
2125        let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
2126        let mut touched_ids: Vec<EntityId> = Vec::new();
2127        let claim_cap = query.claim_limit.map(|limit| limit as usize);
2128        let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
2129        let manager = store
2130            .get_collection(&query.table)
2131            .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
2132        let scan_limit = if query.order_by.is_empty() {
2133            limit_cap
2134        } else {
2135            None
2136        };
2137        let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
2138            self,
2139            &query.table,
2140            effective_filter.as_ref(),
2141            scan_limit,
2142            query.target,
2143        );
2144        if needs_rmw_lock {
2145            target_scan = target_scan.with_live_table_rows();
2146        }
2147        let ids_to_update = {
2148            let _topology_guard = topology_lock.read();
2149            target_scan.find_target_ids()?
2150        };
2151        let order_limit = if query.claim_limit.is_some() {
2152            None
2153        } else {
2154            limit_cap
2155        };
2156        let ids_to_update = if query.order_by.is_empty() {
2157            ids_to_update
2158        } else {
2159            ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
2160        };
2161        let mut ids_to_update = if query.claim_limit.is_some() {
2162            self.filter_claim_locked_target_ids(&query.table, ids_to_update)
2163        } else {
2164            ids_to_update
2165        };
2166        if let Some(claim_cap) = claim_cap {
2167            ids_to_update.truncate(claim_cap);
2168        }
2169        if query.claim_exact
2170            && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
2171        {
2172            self.inner
2173                .claim_telemetry
2174                .record_miss(&query.table, claim_model);
2175            return Ok((
2176                RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
2177                Vec::new(),
2178            ));
2179        }
2180
2181        if needs_rmw_lock {
2182            let result = self.execute_update_inner_tracked_locked(
2183                raw_query,
2184                query,
2185                &compiled_plan,
2186                &ids_to_update,
2187                effective_filter.as_ref(),
2188            )?;
2189            if query.claim_limit.is_some() {
2190                self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
2191            }
2192            record_claim_outcome(
2193                &self.inner.claim_telemetry,
2194                query.claim_limit,
2195                &query.table,
2196                claim_model,
2197                result.0.affected_rows,
2198            );
2199            return Ok(result);
2200        }
2201
2202        // Keep topology fixed while reserving every post-image. No chunk is
2203        // published until all targets fit; a later denial cannot leave earlier
2204        // autocommit chunks visible. Prepared payload is bounded by admission.
2205        let topology_guard = topology_lock.read();
2206        let mut prepared = Vec::new();
2207        let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2208        for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2209            for entity in manager.get_many(chunk).into_iter().flatten() {
2210                let assignments =
2211                    self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2212                let applied = self.apply_materialized_update_for_entity(
2213                    query,
2214                    entity,
2215                    &compiled_plan,
2216                    assignments,
2217                )?;
2218                reservations.admit(&applied)?;
2219                touched_ids.push(applied.id);
2220                prepared.push(applied);
2221            }
2222        }
2223        let affected = prepared.len() as u64;
2224        let mut published = 0;
2225        let persistence = (|| -> RedDBResult<()> {
2226            for chunk in prepared.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2227                if compiled_plan.row_touches_unique_columns
2228                    || compiled_plan
2229                        .row_contract_plan
2230                        .as_ref()
2231                        .is_some_and(|plan| plan.has_expressions)
2232                {
2233                    let db = self.db();
2234                    let contract = crate::application::collection_contract_enforcer::CollectionContractWriteEnforcer::new(self, &db, &query.table);
2235                    for item in chunk {
2236                        let fields =
2237                            crate::application::ports::entity_row_fields_snapshot(&item.entity);
2238                        let previous_id =
2239                            item.replaced_entity.as_ref().map_or(item.id, |old| old.id);
2240                        contract.enforce_row_uniqueness(&fields, Some(previous_id))?;
2241                    }
2242                }
2243                self.persist_update_chunk(chunk)?;
2244                published += chunk.len();
2245            }
2246            Ok(())
2247        })();
2248        // A later storage/constraint error must still finish index and CDC
2249        // maintenance for earlier published chunks, as the chunked path did.
2250        let published_mutations = &prepared[..published];
2251        let lsns = self.flush_update_chunk(published_mutations, topology_guard)?;
2252        if !query.suppress_events {
2253            self.emit_update_events_for_collection(&query.table, published_mutations, &lsns)?;
2254        }
2255        persistence?;
2256        drop(reservations);
2257
2258        if affected > 0 {
2259            self.note_table_write(&query.table);
2260        }
2261        if query.claim_limit.is_some() {
2262            self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2263        }
2264        record_claim_outcome(
2265            &self.inner.claim_telemetry,
2266            query.claim_limit,
2267            &query.table,
2268            claim_model,
2269            affected,
2270        );
2271
2272        Ok((
2273            RuntimeQueryResult::dml_result(
2274                raw_query.to_string(),
2275                affected,
2276                "update",
2277                "runtime-dml",
2278            ),
2279            touched_ids,
2280        ))
2281    }
2282
2283    fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2284        let conn_id = current_connection_id();
2285        let locks = self.inner.pending_claim_locks.read();
2286        ids.into_iter()
2287            .filter(|id| {
2288                let Some(entity) = self.inner.db.store().get(table, *id) else {
2289                    return false;
2290                };
2291                let key = (table.to_string(), entity.logical_id());
2292                locks
2293                    .get(&key)
2294                    .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2295            })
2296            .collect()
2297    }
2298
2299    fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2300        if self.current_xid().is_none() || ids.is_empty() {
2301            return;
2302        }
2303
2304        let conn_id = current_connection_id();
2305        let store = self.inner.db.store();
2306        let mut locks = self.inner.pending_claim_locks.write();
2307        for id in ids {
2308            let Some(entity) = store.get(table, *id) else {
2309                continue;
2310            };
2311            locks.insert((table.to_string(), entity.logical_id()), conn_id);
2312        }
2313    }
2314
2315    fn execute_update_inner_tracked_locked(
2316        &self,
2317        raw_query: &str,
2318        query: &UpdateQuery,
2319        compiled_plan: &CompiledUpdatePlan,
2320        ids_to_update: &[EntityId],
2321        effective_filter: Option<&Filter>,
2322    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2323        // Existing table/claim locks precede topology; per-row RMW locks
2324        // below follow it. Keep the same index set through persistence/flush.
2325        let topology_lock = self
2326            .index_store_ref()
2327            .collection_topology_lock(&query.table);
2328        let topology_guard = topology_lock.read();
2329        let store = self.inner.db.store();
2330        let mut touched_ids = Vec::new();
2331        let mut lock_entries = Vec::new();
2332
2333        for id in ids_to_update {
2334            let Some(candidate) = store.get(&query.table, *id) else {
2335                continue;
2336            };
2337            let logical_id = candidate.logical_id();
2338            let lock_key = format!("row:{}", logical_id.raw());
2339            let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2340            lock_entries.push((lock_key, logical_id, rmw_lock));
2341        }
2342
2343        lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2344        lock_entries.dedup_by(|left, right| left.0 == right.0);
2345        let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2346
2347        let mut applied_chunk = Vec::new();
2348        let mut reservations = super::memory_admission::MutationMemoryReservations::new(self);
2349        for (_, logical_id, _) in &lock_entries {
2350            let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2351            else {
2352                continue;
2353            };
2354            if let Some(filter) = effective_filter {
2355                if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2356                    Some(self.inner.db.as_ref()),
2357                    &entity,
2358                    filter,
2359                    &query.table,
2360                    &query.table,
2361                ) {
2362                    continue;
2363                }
2364            }
2365
2366            let assignments =
2367                self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2368            let applied = self.apply_materialized_update_for_entity(
2369                query,
2370                entity,
2371                compiled_plan,
2372                assignments,
2373            )?;
2374            reservations.admit(&applied)?;
2375            touched_ids.push(applied.id);
2376            applied_chunk.push(applied);
2377        }
2378
2379        let affected = applied_chunk.len() as u64;
2380        if !applied_chunk.is_empty() {
2381            self.persist_update_chunk(&applied_chunk)?;
2382            let lsns = self.flush_update_chunk(&applied_chunk, topology_guard)?;
2383            if !query.suppress_events {
2384                self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2385            }
2386        }
2387
2388        if affected > 0 {
2389            self.note_table_write(&query.table);
2390        }
2391
2392        Ok((
2393            RuntimeQueryResult::dml_result(
2394                raw_query.to_string(),
2395                affected,
2396                "update",
2397                "runtime-dml",
2398            ),
2399            touched_ids,
2400        ))
2401    }
2402
2403    fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2404        let mut static_field_assignments = Vec::new();
2405        let mut static_metadata_assignments = Vec::new();
2406        let mut dynamic_assignments = Vec::new();
2407        let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2408        let mut row_modified_columns = Vec::new();
2409
2410        for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2411            let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2412            let metadata_key = resolve_sql_ttl_metadata_key(column);
2413            if compound_op.is_some() && metadata_key.is_some() {
2414                return Err(RedDBError::Query(format!(
2415                    "compound assignment is only supported for row fields: {column}"
2416                )));
2417            }
2418            if compound_op.is_none() {
2419                if let Ok(value) = fold_expr_to_value(expr.clone()) {
2420                    if let Some(metadata_key) = metadata_key {
2421                        let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2422                        let (canonical_key, canonical_value) =
2423                            canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2424                        static_metadata_assignments
2425                            .push((canonical_key.to_string(), canonical_value));
2426                    } else {
2427                        let value = self.resolve_crypto_sentinel(value)?;
2428                        static_field_assignments.push((
2429                            column.clone(),
2430                            normalize_row_update_assignment_with_plan(
2431                                &query.table,
2432                                column,
2433                                value,
2434                                row_contract_plan.as_ref(),
2435                            )?,
2436                        ));
2437                        row_modified_columns.push(column.clone());
2438                    }
2439                    continue;
2440                }
2441            }
2442
2443            dynamic_assignments.push(CompiledUpdateAssignment {
2444                column: column.clone(),
2445                expr: expr.clone(),
2446                compound_op,
2447                metadata_key,
2448                row_rule: if metadata_key.is_none() {
2449                    if let Some(plan) = row_contract_plan.as_ref() {
2450                        if plan.timestamps_enabled
2451                            && (column == "created_at" || column == "updated_at")
2452                        {
2453                            return Err(RedDBError::Query(format!(
2454                                "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2455                                query.table, column
2456                            )));
2457                        }
2458                        if let Some(rule) = plan.declared_rules.get(column) {
2459                            Some(rule.clone())
2460                        } else if plan.strict_schema {
2461                            return Err(RedDBError::Query(format!(
2462                                "collection '{}' is strict and does not allow undeclared fields: {}",
2463                                query.table, column
2464                            )));
2465                        } else {
2466                            None
2467                        }
2468                    } else {
2469                        None
2470                    }
2471                } else {
2472                    None
2473                },
2474            });
2475            if metadata_key.is_none() {
2476                row_modified_columns.push(column.clone());
2477            }
2478        }
2479
2480        let row_modified_columns = dedupe_update_columns(row_modified_columns);
2481        let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2482            row_modified_columns.iter().any(|column| {
2483                plan.unique_columns
2484                    .keys()
2485                    .any(|unique| unique.eq_ignore_ascii_case(column))
2486            })
2487        });
2488
2489        if let Some(ttl_ms) = query.ttl_ms {
2490            static_metadata_assignments
2491                .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2492        }
2493        if let Some(expires_at_ms) = query.expires_at_ms {
2494            static_metadata_assignments.push((
2495                "_expires_at".to_string(),
2496                metadata_u64_to_value(expires_at_ms),
2497            ));
2498        }
2499        for (key, val) in &query.with_metadata {
2500            static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2501        }
2502
2503        Ok(CompiledUpdatePlan {
2504            static_field_assignments,
2505            static_metadata_assignments,
2506            dynamic_assignments,
2507            row_contract_plan,
2508            row_modified_columns,
2509            row_touches_unique_columns,
2510        })
2511    }
2512
2513    fn materialize_update_assignments_for_entity(
2514        &self,
2515        query: &UpdateQuery,
2516        entity: &UnifiedEntity,
2517        compiled_plan: &CompiledUpdatePlan,
2518    ) -> RedDBResult<MaterializedUpdateAssignments> {
2519        let mut assignments = MaterializedUpdateAssignments::default();
2520        let mut record: Option<UnifiedRecord> = None;
2521
2522        for assignment in &compiled_plan.dynamic_assignments {
2523            if assignment.compound_op.is_some()
2524                && !matches!(
2525                    entity.data,
2526                    EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2527                )
2528            {
2529                return Err(RedDBError::Query(format!(
2530                    "compound assignment is only supported for row or graph UPDATE column '{}'",
2531                    assignment.column
2532                )));
2533            }
2534            if record.is_none() {
2535                record = runtime_any_record_from_entity_ref(entity);
2536            }
2537            let Some(record) = record.as_ref() else {
2538                return Err(RedDBError::Query(format!(
2539                    "UPDATE could not materialize runtime record for entity {} in '{}'",
2540                    entity.id.raw(),
2541                    query.table
2542                )));
2543            };
2544            let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2545                Some(self.inner.db.as_ref()),
2546                &assignment.expr,
2547                record,
2548                Some(query.table.as_str()),
2549                Some(query.table.as_str()),
2550            )
2551            .ok_or_else(|| {
2552                RedDBError::Query(format!(
2553                    "failed to evaluate UPDATE expression for column '{}'",
2554                    assignment.column
2555                ))
2556            })?;
2557            let value = if let Some(op) = assignment.compound_op {
2558                evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2559            } else {
2560                rhs
2561            };
2562
2563            if let Some(metadata_key) = assignment.metadata_key {
2564                let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2565                let (canonical_key, canonical_value) =
2566                    canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2567                assignments
2568                    .dynamic_metadata_assignments
2569                    .push((canonical_key.to_string(), canonical_value));
2570            } else {
2571                assignments.dynamic_field_assignments.push((
2572                    assignment.column.clone(),
2573                    normalize_row_update_value_for_rule(
2574                        &query.table,
2575                        self.resolve_crypto_sentinel(value)?,
2576                        assignment.row_rule.as_ref(),
2577                    )?,
2578                ));
2579            }
2580        }
2581
2582        Ok(assignments)
2583    }
2584
2585    fn apply_materialized_update_for_entity(
2586        &self,
2587        query: &UpdateQuery,
2588        entity: UnifiedEntity,
2589        compiled_plan: &CompiledUpdatePlan,
2590        mut assignments: MaterializedUpdateAssignments,
2591    ) -> RedDBResult<AppliedEntityMutation> {
2592        if matches!(query.target, UpdateTarget::Kv)
2593            && !compiled_plan
2594                .static_field_assignments
2595                .iter()
2596                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2597            && !assignments
2598                .dynamic_field_assignments
2599                .iter()
2600                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2601        {
2602            if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2603                .and_then(|record| record.get("key").cloned())
2604            {
2605                assignments
2606                    .dynamic_field_assignments
2607                    .push(("key".to_string(), key));
2608            }
2609        }
2610        if matches!(entity.data, EntityData::Row(_)) {
2611            return self.apply_loaded_sql_update_row_core(
2612                query.table.clone(),
2613                entity,
2614                &compiled_plan.static_field_assignments,
2615                assignments.dynamic_field_assignments,
2616                &compiled_plan.static_metadata_assignments,
2617                assignments.dynamic_metadata_assignments,
2618                compiled_plan.row_contract_plan.as_ref(),
2619                &compiled_plan.row_modified_columns,
2620                compiled_plan.row_touches_unique_columns,
2621            );
2622        }
2623
2624        ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2625
2626        let operations = build_patch_operations_from_materialized_assignments(
2627            &entity,
2628            compiled_plan,
2629            assignments,
2630        );
2631        self.apply_loaded_patch_entity_core(
2632            query.table.clone(),
2633            entity,
2634            crate::json::Value::Null,
2635            operations,
2636        )
2637    }
2638
2639    /// Execute DELETE FROM table WHERE filter
2640    pub fn execute_delete(
2641        &self,
2642        raw_query: &str,
2643        query: &DeleteQuery,
2644    ) -> RedDBResult<RuntimeQueryResult> {
2645        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2646        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2647            self,
2648            &query.table,
2649        );
2650        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2651        // Issue #523 — blockchain collections are immutable; see
2652        // execute_update for the same gate.
2653        if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2654            return Err(RedDBError::InvalidOperation(format!(
2655                "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2656                query.table
2657            )));
2658        }
2659        // CollectionContract gate (#50) — see execute_update for
2660        // rationale. The gate handles APPEND ONLY rejection and is
2661        // the single point where future contract bits land.
2662        crate::runtime::collection_contract::CollectionContractGate::check(
2663            self,
2664            &query.table,
2665            crate::runtime::collection_contract::MutationKind::Delete,
2666        )?;
2667
2668        // RETURNING on DELETE: capture the pre-image via an internal
2669        // SELECT that reuses the same WHERE, then run the delete with
2670        // the RETURNING clause stripped, then project the captured
2671        // rows through the requested items. The extra SELECT is a
2672        // pragmatic MVP — a future pass can fuse the scan with the
2673        // delete to avoid the second pass over the heap.
2674        if let Some(items) = query.returning.clone() {
2675            let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2676                RedDBError::Query(
2677                    "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2678                )
2679            })?;
2680            let captured = self.execute_query(&select_sql)?;
2681
2682            let mut inner_query = query.clone();
2683            inner_query.returning = None;
2684            let _ = self.execute_delete(raw_query, &inner_query)?;
2685
2686            let snapshots: Vec<Vec<(String, Value)>> = captured
2687                .result
2688                .records
2689                .iter()
2690                .map(|rec| {
2691                    rec.iter_fields()
2692                        .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2693                        .collect()
2694                })
2695                .collect();
2696            let affected = snapshots.len() as u64;
2697            let result = build_returning_result(&items, &snapshots, None);
2698
2699            let mut response = RuntimeQueryResult::dml_result(
2700                raw_query.to_string(),
2701                affected,
2702                "delete",
2703                "runtime-dml-returning",
2704            );
2705            response.result = result;
2706            return Ok(response);
2707        }
2708        // Row-Level Security enforcement (Phase 2.5.2 PG parity).
2709        //
2710        // When the table has RLS enabled, gate the DELETE by the
2711        // per-role policy set: mutations only touch rows that *every*
2712        // matching `FOR DELETE` policy would accept. No policies =>
2713        // zero rows affected (PG restrictive-default).
2714        if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2715            let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2716                self,
2717                &query.table,
2718                reddb_rql::ast::PolicyAction::Delete,
2719            );
2720            let Some(policy) = rls_filter else {
2721                return Ok(RuntimeQueryResult::dml_result(
2722                    raw_query.to_string(),
2723                    0,
2724                    "delete",
2725                    "runtime-dml-rls",
2726                ));
2727            };
2728            // Fold the policy predicate into the user's WHERE before
2729            // dispatching — the remainder of this function reads the
2730            // filter from `query` via `effective_delete_filter`, which
2731            // respects the updated value.
2732            let mut augmented = query.clone();
2733            augmented.filter = Some(match augmented.filter.take() {
2734                Some(existing) => reddb_rql::ast::Filter::And(Box::new(existing), Box::new(policy)),
2735                None => policy,
2736            });
2737            return self.execute_delete_inner(raw_query, &augmented);
2738        }
2739        self.execute_delete_inner(raw_query, query)
2740    }
2741
2742    fn execute_delete_inner(
2743        &self,
2744        raw_query: &str,
2745        query: &DeleteQuery,
2746    ) -> RedDBResult<RuntimeQueryResult> {
2747        let topology_lock = self
2748            .index_store_ref()
2749            .collection_topology_lock(&query.table);
2750        let effective_filter = effective_delete_filter(query);
2751
2752        // Find the rows that match the WHERE clause. The "find target
2753        // rows" loop lives in DmlTargetScan so UPDATE (#52) can reuse
2754        // the same scan strategy.
2755        let scan = super::dml_target_scan::DmlTargetScan::new(
2756            self,
2757            &query.table,
2758            effective_filter.as_ref(),
2759            None,
2760        );
2761        let ids_to_delete = {
2762            let _topology_guard = topology_lock.read();
2763            scan.find_target_ids()?
2764        };
2765
2766        // For event-enabled collections, snapshot the pre-delete state
2767        // before rows are physically removed.
2768        let needs_delete_events =
2769            !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2770        let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2771            scan.row_json_pre_images(&ids_to_delete)
2772        } else {
2773            HashMap::new()
2774        };
2775
2776        let mut affected: u64 = 0;
2777        for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2778            let (count, lsns) =
2779                self.delete_entities_batch(&query.table, chunk, topology_lock.read())?;
2780            #[cfg(test)]
2781            self.index_store_ref().mutation_test_hook(
2782                &query.table,
2783                super::index_store::MutationTestPhase::BeforeEvents,
2784            );
2785            affected += count;
2786            if needs_delete_events && !lsns.is_empty() {
2787                // lsns.len() == actually-deleted entities; align with chunk ids.
2788                // `delete_batch` may skip missing entities, so we correlate by
2789                // the number returned (they're emitted in chunk order).
2790                let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2791                self.emit_delete_events_for_collection(
2792                    &query.table,
2793                    deleted_chunk,
2794                    &lsns,
2795                    &pre_images,
2796                )?;
2797            }
2798        }
2799        pre_images.clear();
2800
2801        if affected > 0 {
2802            self.note_table_write(&query.table);
2803        }
2804
2805        Ok(RuntimeQueryResult::dml_result(
2806            raw_query.to_string(),
2807            affected,
2808            "delete",
2809            "runtime-dml",
2810        ))
2811    }
2812}
2813
2814#[cfg(test)]
2815mod tests {
2816    use crate::storage::wal::{WalReader, WalRecord};
2817    use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2818    use crate::{RedDBOptions, RedDBRuntime};
2819    use reddb_types::Value;
2820    use std::path::Path;
2821
2822    fn persistent_operational_options(path: &Path) -> RedDBOptions {
2823        RedDBOptions::persistent(path)
2824            .with_storage_profile(StorageProfileSelection {
2825                deploy_profile: DeployProfile::Embedded,
2826                packaging: StoragePackaging::OperationalDirectory,
2827                replica_count: 0,
2828                managed_backup: false,
2829                wal_retention: false,
2830            })
2831            .unwrap()
2832    }
2833
2834    fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
2835        WalReader::open(wal_path)
2836            .expect("wal opens")
2837            .iter()
2838            .map(|record| record.expect("wal record decodes").1)
2839            .filter_map(|record| match record {
2840                WalRecord::TxCommitBatch { actions, .. } => Some(actions),
2841                _ => None,
2842            })
2843            .collect()
2844    }
2845
2846    fn action_contains_text(action: &[u8], needle: &str) -> bool {
2847        action
2848            .windows(needle.len())
2849            .any(|window| window == needle.as_bytes())
2850    }
2851
2852    fn claim_metric_count(
2853        snapshot: &crate::runtime::ClaimTelemetrySnapshot,
2854        metric: &str,
2855        collection: &str,
2856        model: &str,
2857    ) -> u64 {
2858        let rows = match metric {
2859            "attempts" => &snapshot.attempts,
2860            "successful" => &snapshot.successful,
2861            "misses" => &snapshot.misses,
2862            "skipped_locked" => &snapshot.skipped_locked,
2863            other => panic!("unknown claim metric {other}"),
2864        };
2865        rows.iter()
2866            .find(|((actual_collection, actual_model), _)| {
2867                actual_collection == collection && actual_model == model
2868            })
2869            .map(|(_, count)| *count)
2870            .unwrap_or(0)
2871    }
2872
2873    fn assert_statement_writes_collections_in_one_new_wal_batch(
2874        rt: &RedDBRuntime,
2875        wal_path: &Path,
2876        statement: &str,
2877        source: &str,
2878        event_queue: &str,
2879    ) {
2880        let before_batches = store_commit_batches(wal_path).len();
2881
2882        rt.execute_query(statement).unwrap();
2883
2884        let batches = store_commit_batches(wal_path);
2885        let statement_batches = &batches[before_batches..];
2886        let source_batch = statement_batches
2887            .iter()
2888            .position(|actions| {
2889                actions.iter().any(|action| {
2890                    action_contains_text(action, source)
2891                        && !action_contains_text(action, event_queue)
2892                })
2893            })
2894            .expect("source collection write batch is present");
2895        let event_batch = statement_batches
2896            .iter()
2897            .position(|actions| {
2898                actions
2899                    .iter()
2900                    .any(|action| action_contains_text(action, event_queue))
2901            })
2902            .expect("event queue write batch is present");
2903
2904        assert_eq!(
2905            source_batch, event_batch,
2906            "WITH EVENTS must persist the source write and queue event in the same WAL batch"
2907        );
2908    }
2909
2910    #[test]
2911    fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
2912        let dir = tempfile::tempdir().unwrap();
2913        let db_path = dir.path().join("events_dual_write.rdb");
2914        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2915        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2916
2917        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2918            .unwrap();
2919        assert_statement_writes_collections_in_one_new_wal_batch(
2920            &rt,
2921            &wal_path,
2922            "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
2923            "users",
2924            "users_events",
2925        );
2926    }
2927
2928    #[test]
2929    fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
2930        let dir = tempfile::tempdir().unwrap();
2931        let db_path = dir.path().join("events_update_atomic.rdb");
2932        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2933        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2934
2935        rt.execute_query(
2936            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
2937        )
2938        .unwrap();
2939        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2940            .unwrap();
2941
2942        assert_statement_writes_collections_in_one_new_wal_batch(
2943            &rt,
2944            &wal_path,
2945            "UPDATE users SET email = 'b@example.test' WHERE id = 1",
2946            "users",
2947            "user_updates",
2948        );
2949    }
2950
2951    #[test]
2952    fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
2953        let dir = tempfile::tempdir().unwrap();
2954        let db_path = dir.path().join("events_delete_atomic.rdb");
2955        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2956        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2957
2958        rt.execute_query(
2959            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
2960        )
2961        .unwrap();
2962        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2963            .unwrap();
2964
2965        assert_statement_writes_collections_in_one_new_wal_batch(
2966            &rt,
2967            &wal_path,
2968            "DELETE FROM users WHERE id = 1",
2969            "users",
2970            "user_deletes",
2971        );
2972    }
2973
2974    #[test]
2975    fn update_where_id_in_with_hash_index_updates_expected_rows() {
2976        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2977        rt.execute_query("CREATE TABLE users (id INT, score INT)")
2978            .unwrap();
2979        for id in 0..5 {
2980            rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
2981                .unwrap();
2982        }
2983        rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
2984            .unwrap();
2985
2986        let updated = rt
2987            .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
2988            .unwrap();
2989        assert_eq!(updated.affected_rows, 3);
2990
2991        let selected = rt
2992            .execute_query("SELECT id, score FROM users ORDER BY id")
2993            .unwrap();
2994        let scores: Vec<(i64, i64)> = selected
2995            .result
2996            .records
2997            .iter()
2998            .map(|record| {
2999                let id = match record.get("id").unwrap() {
3000                    Value::Integer(value) => *value,
3001                    other => panic!("expected integer id, got {other:?}"),
3002                };
3003                let score = match record.get("score").unwrap() {
3004                    Value::Integer(value) => *value,
3005                    other => panic!("expected integer score, got {other:?}"),
3006                };
3007                (id, score)
3008            })
3009            .collect();
3010        assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
3011    }
3012
3013    /// Drives UPDATE through the shared `DmlTargetScan` module — the
3014    /// same code path DELETE uses (#51, #52). Exercises the indexed
3015    /// equality fast-path (WHERE id = N with a HASH index), the
3016    /// unindexed range scan (WHERE score > N), and the no-WHERE
3017    /// full-scan branch to confirm the extracted "find target rows"
3018    /// loop preserves affected-row counts and the resulting row state.
3019    #[test]
3020    fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3021        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3022        rt.execute_query("CREATE TABLE items (id INT, score INT)")
3023            .unwrap();
3024        for id in 0..5 {
3025            rt.execute_query(&format!(
3026                "INSERT INTO items (id, score) VALUES ({id}, {})",
3027                id * 10
3028            ))
3029            .unwrap();
3030        }
3031        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3032            .unwrap();
3033
3034        // Indexed equality UPDATE — hits the hash fast-path inside
3035        // DmlTargetScan::find_target_ids. id=2 has score=20, drop it
3036        // below the score>25 cutoff so the next assertion stays clean.
3037        let updated_one = rt
3038            .execute_query("UPDATE items SET score = 5 WHERE id = 2")
3039            .unwrap();
3040        assert_eq!(updated_one.affected_rows, 1);
3041
3042        // Unindexed scan UPDATE — bumps everyone with score > 25,
3043        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
3044        // zoned/full-scan branch.
3045        let updated_many = rt
3046            .execute_query("UPDATE items SET score = 7 WHERE score > 25")
3047            .unwrap();
3048        assert_eq!(updated_many.affected_rows, 2);
3049
3050        let snapshot = rt
3051            .execute_query("SELECT id, score FROM items ORDER BY id")
3052            .unwrap();
3053        let pairs: Vec<(i64, i64)> = snapshot
3054            .result
3055            .records
3056            .iter()
3057            .map(|record| {
3058                let id = match record.get("id").unwrap() {
3059                    Value::Integer(value) => *value,
3060                    other => panic!("expected integer id, got {other:?}"),
3061                };
3062                let score = match record.get("score").unwrap() {
3063                    Value::Integer(value) => *value,
3064                    other => panic!("expected integer score, got {other:?}"),
3065                };
3066                (id, score)
3067            })
3068            .collect();
3069        assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
3070
3071        // Full-scan UPDATE with no WHERE rewrites every remaining row.
3072        let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
3073        assert_eq!(updated_all.affected_rows, 5);
3074        let after = rt
3075            .execute_query("SELECT score FROM items ORDER BY id")
3076            .unwrap();
3077        let scores: Vec<i64> = after
3078            .result
3079            .records
3080            .iter()
3081            .map(|record| match record.get("score").unwrap() {
3082                Value::Integer(value) => *value,
3083                other => panic!("expected integer score, got {other:?}"),
3084            })
3085            .collect();
3086        assert_eq!(scores, vec![1, 1, 1, 1, 1]);
3087    }
3088
3089    /// Drives DELETE through the new `DmlTargetScan` module. Exercises
3090    /// both the index fast-path (WHERE id = N with a HASH index) and
3091    /// the unindexed scan path (WHERE score > N) to confirm the
3092    /// extracted "find target rows" loop preserves the affected-row
3093    /// count and which rows survive.
3094    #[test]
3095    fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
3096        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3097        rt.execute_query("CREATE TABLE items (id INT, score INT)")
3098            .unwrap();
3099        for id in 0..5 {
3100            rt.execute_query(&format!(
3101                "INSERT INTO items (id, score) VALUES ({id}, {})",
3102                id * 10
3103            ))
3104            .unwrap();
3105        }
3106        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
3107            .unwrap();
3108
3109        // Indexed equality DELETE — hits the hash fast-path inside
3110        // DmlTargetScan::find_target_ids.
3111        let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
3112        assert_eq!(deleted_one.affected_rows, 1);
3113
3114        // Unindexed scan DELETE — drops everyone with score > 25,
3115        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
3116        // zoned/full-scan branch.
3117        let deleted_many = rt
3118            .execute_query("DELETE FROM items WHERE score > 25")
3119            .unwrap();
3120        assert_eq!(deleted_many.affected_rows, 2);
3121
3122        let surviving = rt
3123            .execute_query("SELECT id FROM items ORDER BY id")
3124            .unwrap();
3125        let ids: Vec<i64> = surviving
3126            .result
3127            .records
3128            .iter()
3129            .map(|record| match record.get("id").unwrap() {
3130                Value::Integer(value) => *value,
3131                other => panic!("expected integer id, got {other:?}"),
3132            })
3133            .collect();
3134        assert_eq!(ids, vec![0, 1]);
3135
3136        // Sanity: full-scan DELETE with no WHERE clears the rest.
3137        let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
3138        assert_eq!(deleted_rest.affected_rows, 2);
3139        let empty = rt.execute_query("SELECT id FROM items").unwrap();
3140        assert!(empty.result.records.is_empty());
3141    }
3142
3143    /// CollectionContract gate (#49 + #50): APPEND ONLY tables accept
3144    /// INSERT but reject UPDATE and DELETE with the documented
3145    /// operator-facing error strings. Drives all three DML verbs so
3146    /// the centralized gate is exercised end-to-end.
3147    #[test]
3148    fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
3149        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3150        rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
3151            .unwrap();
3152
3153        // INSERT must succeed — APPEND ONLY exists precisely to allow
3154        // appends. The gate should be a no-op for INSERT.
3155        let inserted = rt
3156            .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
3157            .unwrap();
3158        assert_eq!(inserted.affected_rows, 1);
3159
3160        // UPDATE is rejected with the gate's UPDATE-specific message.
3161        let update_err = rt
3162            .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
3163            .unwrap_err();
3164        let msg = format!("{update_err}");
3165        assert!(
3166            msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
3167            "expected UPDATE rejection message, got: {msg}"
3168        );
3169
3170        // DELETE is rejected with the gate's DELETE-specific message.
3171        let delete_err = rt
3172            .execute_query("DELETE FROM events WHERE id = 1")
3173            .unwrap_err();
3174        let msg = format!("{delete_err}");
3175        assert!(
3176            msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
3177            "expected DELETE rejection message, got: {msg}"
3178        );
3179
3180        // Row should still be present — neither rejected mutation
3181        // touched storage.
3182        let surviving = rt.execute_query("SELECT id FROM events").unwrap();
3183        assert_eq!(surviving.result.records.len(), 1);
3184    }
3185
3186    /// CollectionContract gate: tables without an APPEND ONLY contract
3187    /// permit INSERT, UPDATE, and DELETE — the gate's default branch
3188    /// is a true pass-through, not an accidental block.
3189    #[test]
3190    fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
3191        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3192        rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
3193            .unwrap();
3194
3195        rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
3196            .unwrap();
3197        let updated = rt
3198            .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
3199            .unwrap();
3200        assert_eq!(updated.affected_rows, 1);
3201        let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
3202        assert_eq!(deleted.affected_rows, 1);
3203    }
3204
3205    #[test]
3206    fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
3207        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3208        rt.execute_query(
3209            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
3210        )
3211        .unwrap();
3212
3213        let inserted = rt
3214            .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
3215            .unwrap();
3216        assert_eq!(inserted.affected_rows, 1);
3217
3218        let events = queue_payloads(&rt, "audit_log");
3219        assert_eq!(events.len(), 1);
3220        let event = events[0].as_object().expect("event payload object");
3221        assert!(event
3222            .get("event_id")
3223            .and_then(crate::json::Value::as_str)
3224            .is_some_and(|value| !value.is_empty()));
3225        assert_eq!(
3226            event.get("op").and_then(crate::json::Value::as_str),
3227            Some("insert")
3228        );
3229        assert_eq!(
3230            event.get("collection").and_then(crate::json::Value::as_str),
3231            Some("users")
3232        );
3233        assert_eq!(
3234            event.get("id").and_then(crate::json::Value::as_u64),
3235            Some(7)
3236        );
3237        assert!(event
3238            .get("ts")
3239            .and_then(crate::json::Value::as_u64)
3240            .is_some());
3241        assert!(event
3242            .get("lsn")
3243            .and_then(crate::json::Value::as_u64)
3244            .is_some());
3245        assert!(matches!(
3246            event.get("tenant"),
3247            Some(crate::json::Value::Null)
3248        ));
3249        assert!(matches!(
3250            event.get("before"),
3251            Some(crate::json::Value::Null)
3252        ));
3253        let after = event
3254            .get("after")
3255            .and_then(crate::json::Value::as_object)
3256            .expect("after object");
3257        assert_eq!(
3258            after.get("id").and_then(crate::json::Value::as_u64),
3259            Some(7)
3260        );
3261        assert_eq!(
3262            after.get("email").and_then(crate::json::Value::as_str),
3263            Some("a@example.com")
3264        );
3265    }
3266
3267    #[test]
3268    fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
3269        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3270        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
3271            .unwrap();
3272
3273        rt.execute_query(
3274            "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
3275        )
3276        .unwrap();
3277
3278        let events = queue_payloads(&rt, "users_events");
3279        assert_eq!(events.len(), 2);
3280        let mut previous_lsn = 0;
3281        for (event, expected_id) in events.iter().zip([1_u64, 2]) {
3282            let object = event.as_object().expect("event payload object");
3283            assert_eq!(
3284                object.get("op").and_then(crate::json::Value::as_str),
3285                Some("insert")
3286            );
3287            assert_eq!(
3288                object.get("id").and_then(crate::json::Value::as_u64),
3289                Some(expected_id)
3290            );
3291            let lsn = object
3292                .get("lsn")
3293                .and_then(crate::json::Value::as_u64)
3294                .expect("event lsn");
3295            assert!(
3296                lsn > previous_lsn,
3297                "event LSNs should increase in row order"
3298            );
3299            previous_lsn = lsn;
3300            let after = object
3301                .get("after")
3302                .and_then(crate::json::Value::as_object)
3303                .expect("after object");
3304            assert_eq!(
3305                after.get("id").and_then(crate::json::Value::as_u64),
3306                Some(expected_id)
3307            );
3308        }
3309    }
3310
3311    #[test]
3312    fn claim_metrics_increment_skipped_locked_without_counting_miss() {
3313        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
3314        rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
3315            .expect("create table");
3316        // ADR 0063: index-backed claim ordering on `rank`.
3317        rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
3318            .expect("create index");
3319        rt.execute_query(
3320            "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
3321             (1, 10, 'ready'), (2, 20, 'ready')",
3322        )
3323        .expect("insert rows");
3324
3325        let claim_lock = rt
3326            .inner
3327            .rmw_locks
3328            .lock_for("claim_metric_locked", "__table_claim_update__");
3329        let _guard = claim_lock.lock();
3330        let updated = rt
3331            .execute_query(
3332                "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
3333                 CLAIM LIMIT 2 ORDER BY rank ASC",
3334            )
3335            .expect("claim skips locked candidates");
3336
3337        assert_eq!(updated.affected_rows, 0);
3338        let snapshot = rt.claim_telemetry_snapshot();
3339        assert_eq!(
3340            claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
3341            1
3342        );
3343        assert_eq!(
3344            claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
3345            2
3346        );
3347        assert_eq!(
3348            claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
3349            0
3350        );
3351        assert_eq!(
3352            snapshot.skipped_locked.len(),
3353            1,
3354            "skipped-lock labels stay bounded to collection/model"
3355        );
3356    }
3357
3358    fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
3359        let result = rt
3360            .execute_query(&format!("QUEUE PEEK {queue} 10"))
3361            .expect("peek queue");
3362        result
3363            .result
3364            .records
3365            .iter()
3366            .map(
3367                |record| match record.get("payload").expect("payload column") {
3368                    Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
3369                    other => panic!("expected JSON queue payload, got {other:?}"),
3370                },
3371            )
3372            .collect()
3373    }
3374
3375    // ── #112: auto-index user `id` on first insert ─────────────────────
3376
3377    /// First insert into a fresh collection that carries a column named
3378    /// `id` registers an implicit HASH index on `id`. Subsequent inserts
3379    /// populate it transparently, and `WHERE id = N` lookups exercise
3380    /// the hash-index fast path in `DmlTargetScan::find_target_ids`.
3381    ///
3382    /// This is the load-bearing acceptance test for #112 — without the
3383    /// hook, `find_index_for_column` returns `None` and DELETE/UPDATE
3384    /// fall through to a full segment scan (the 4× perf gap documented
3385    /// in `docs/perf/delete-sequential-2026-05-06.md`).
3386    #[test]
3387    fn auto_index_id_fires_on_first_insert() {
3388        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3389        rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
3390            .unwrap();
3391
3392        // Pre-condition: no index on `id` yet.
3393        assert!(
3394            rt.index_store_ref()
3395                .find_index_for_column("bench_users", "id")
3396                .is_none(),
3397            "freshly created collection should not have an `id` index"
3398        );
3399
3400        // Single-row INSERT — drives `MutationEngine::append_one`.
3401        rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
3402            .unwrap();
3403
3404        // Post-condition: hash index registered on `id`.
3405        let registered = rt
3406            .index_store_ref()
3407            .find_index_for_column("bench_users", "id")
3408            .expect("auto-index hook should have registered idx_id on first insert");
3409        assert_eq!(registered.name, "idx_id");
3410        assert_eq!(registered.collection, "bench_users");
3411        assert_eq!(registered.columns, vec!["id".to_string()]);
3412        assert!(matches!(
3413            registered.method,
3414            super::super::index_store::IndexMethodKind::Hash
3415        ));
3416
3417        // Subsequent inserts populate the index; `WHERE id = N` should
3418        // resolve via the hash fast path and round-trip every row.
3419        for id in 2..=5 {
3420            rt.execute_query(&format!(
3421                "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
3422                id * 10
3423            ))
3424            .unwrap();
3425        }
3426        for id in 1..=5 {
3427            let result = rt
3428                .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
3429                .unwrap();
3430            assert_eq!(
3431                result.result.records.len(),
3432                1,
3433                "id={id} should match one row"
3434            );
3435        }
3436
3437        // Delete via the hash fast-path — exactly the bench scenario the
3438        // perf doc identified as the 4× regression. With the index
3439        // present, `find_target_ids` short-circuits before
3440        // `for_each_entity_zoned` runs.
3441        let deleted = rt
3442            .execute_query("DELETE FROM bench_users WHERE id = 3")
3443            .unwrap();
3444        assert_eq!(deleted.affected_rows, 1);
3445    }
3446
3447    /// Bulk INSERT (the multi-row VALUES path) drives
3448    /// `MutationEngine::append_batch`. The hook must fire there too —
3449    /// otherwise the batch entry points (gRPC binary bulk, HTTP bulk,
3450    /// wire bulk INSERT) skip auto-indexing entirely.
3451    #[test]
3452    fn auto_index_id_fires_on_first_bulk_insert() {
3453        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3454        rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
3455            .unwrap();
3456
3457        rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
3458            .unwrap();
3459
3460        let registered = rt
3461            .index_store_ref()
3462            .find_index_for_column("bench_bulk", "id")
3463            .expect("auto-index hook should fire on first bulk insert");
3464        assert_eq!(registered.name, "idx_id");
3465
3466        // Every row populated via `index_entity_insert_batch`.
3467        for id in 1..=3 {
3468            let result = rt
3469                .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
3470                .unwrap();
3471            assert_eq!(result.result.records.len(), 1);
3472        }
3473    }
3474
3475    /// Hook is a no-op when the row carries no `id` column. Conservative
3476    /// match (case-sensitive `id`) — `Id`, `ID`, and `rid`
3477    /// don't trigger it.
3478    #[test]
3479    fn auto_index_id_skips_when_no_id_column() {
3480        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3481        rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
3482            .unwrap();
3483        rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
3484            .unwrap();
3485
3486        assert!(rt
3487            .index_store_ref()
3488            .find_index_for_column("plain", "id")
3489            .is_none());
3490        assert!(rt
3491            .index_store_ref()
3492            .find_index_for_column("plain", "uid")
3493            .is_none());
3494    }
3495
3496    /// Hook only fires once per collection. If an explicit
3497    /// `CREATE INDEX ... USING BTREE` already covers `id`, the hook
3498    /// detects it via `find_index_for_column` and does NOT clobber it
3499    /// with a HASH index on the next insert.
3500    #[test]
3501    fn auto_index_id_skips_when_index_already_exists() {
3502        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3503        rt.execute_query("CREATE TABLE pre (id INT, score INT)")
3504            .unwrap();
3505        // User-declared BTREE index on `id` before any insert.
3506        rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
3507            .unwrap();
3508        rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
3509            .unwrap();
3510
3511        let registered = rt
3512            .index_store_ref()
3513            .find_index_for_column("pre", "id")
3514            .expect("user index should still be there");
3515        assert_eq!(
3516            registered.name, "user_idx",
3517            "auto-index hook must not overwrite an existing index"
3518        );
3519    }
3520
3521    /// Implicit `idx_id` is reaped when the collection drops. The
3522    /// existing `execute_drop_table` walks `list_indices` and drops every
3523    /// entry — confirm the auto-created index participates.
3524    #[test]
3525    fn auto_index_id_dropped_with_collection() {
3526        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3527        rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
3528            .unwrap();
3529        rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
3530            .unwrap();
3531        assert!(rt
3532            .index_store_ref()
3533            .find_index_for_column("ephemeral", "id")
3534            .is_some());
3535
3536        rt.execute_query("DROP TABLE ephemeral").unwrap();
3537
3538        assert!(
3539            rt.index_store_ref()
3540                .find_index_for_column("ephemeral", "id")
3541                .is_none(),
3542            "implicit `idx_id` must be reaped when its collection drops"
3543        );
3544    }
3545
3546    /// Opt-out via `RedDBOptions::with_auto_index_id(false)` (which
3547    /// forwards to `UnifiedStoreConfig::auto_index_id`). With the knob
3548    /// off, first insert leaves the collection without an `id` index —
3549    /// DELETE/UPDATE fall back to the scan path.
3550    #[test]
3551    fn auto_index_id_disabled_by_config() {
3552        let opts = RedDBOptions::in_memory().with_auto_index_id(false);
3553        let rt = RedDBRuntime::with_options(opts).unwrap();
3554
3555        rt.execute_query("CREATE TABLE off (id INT, score INT)")
3556            .unwrap();
3557        rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
3558            .unwrap();
3559
3560        assert!(
3561            rt.index_store_ref()
3562                .find_index_for_column("off", "id")
3563                .is_none(),
3564            "with auto_index_id=false, no implicit index should be created"
3565        );
3566    }
3567
3568    // ── #293: UPDATE / DELETE events ─────────────────────────────────────
3569
3570    #[test]
3571    fn update_single_row_emits_update_event() {
3572        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3573        rt.execute_query(
3574            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
3575        )
3576        .unwrap();
3577        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3578            .unwrap();
3579
3580        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3581            .unwrap();
3582
3583        let events = queue_payloads(&rt, "audit_log");
3584        assert_eq!(events.len(), 1, "expected exactly 1 update event");
3585        let event = events[0].as_object().expect("event payload object");
3586        assert_eq!(
3587            event.get("op").and_then(crate::json::Value::as_str),
3588            Some("update")
3589        );
3590        assert_eq!(
3591            event.get("collection").and_then(crate::json::Value::as_str),
3592            Some("users")
3593        );
3594        assert!(event
3595            .get("event_id")
3596            .and_then(crate::json::Value::as_str)
3597            .is_some_and(|v| !v.is_empty()));
3598        let before = event
3599            .get("before")
3600            .and_then(crate::json::Value::as_object)
3601            .expect("before must be an object");
3602        let after = event
3603            .get("after")
3604            .and_then(crate::json::Value::as_object)
3605            .expect("after must be an object");
3606        assert_eq!(
3607            before.get("name").and_then(crate::json::Value::as_str),
3608            Some("Alice"),
3609            "before.name should be the old value"
3610        );
3611        assert_eq!(
3612            after.get("name").and_then(crate::json::Value::as_str),
3613            Some("Bob"),
3614            "after.name should be the new value"
3615        );
3616    }
3617
3618    #[test]
3619    fn update_event_only_includes_changed_fields() {
3620        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3621        rt.execute_query(
3622            "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
3623        )
3624        .unwrap();
3625        rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
3626            .unwrap();
3627
3628        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3629            .unwrap();
3630
3631        let events = queue_payloads(&rt, "evts");
3632        assert_eq!(events.len(), 1);
3633        let event = events[0].as_object().unwrap();
3634        let before = event
3635            .get("before")
3636            .and_then(crate::json::Value::as_object)
3637            .unwrap();
3638        let after = event
3639            .get("after")
3640            .and_then(crate::json::Value::as_object)
3641            .unwrap();
3642        // Only changed field included.
3643        assert!(
3644            before.contains_key("name"),
3645            "before must include changed field"
3646        );
3647        assert!(
3648            after.contains_key("name"),
3649            "after must include changed field"
3650        );
3651        // Unchanged fields must not appear.
3652        assert!(
3653            !before.contains_key("email"),
3654            "before must not include unchanged email"
3655        );
3656        assert!(
3657            !after.contains_key("email"),
3658            "after must not include unchanged email"
3659        );
3660    }
3661
3662    #[test]
3663    fn multi_row_update_emits_one_event_per_row() {
3664        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3665        rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
3666            .unwrap();
3667        rt.execute_query(
3668            "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
3669        )
3670        .unwrap();
3671
3672        rt.execute_query("UPDATE items SET status = 'done'")
3673            .unwrap();
3674
3675        let events = queue_payloads(&rt, "evts");
3676        assert_eq!(events.len(), 3, "expected one update event per row");
3677        for event in &events {
3678            let obj = event.as_object().unwrap();
3679            assert_eq!(
3680                obj.get("op").and_then(crate::json::Value::as_str),
3681                Some("update")
3682            );
3683        }
3684    }
3685
3686    #[test]
3687    fn delete_single_row_emits_delete_event() {
3688        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3689        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
3690            .unwrap();
3691        rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
3692            .unwrap();
3693
3694        rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
3695
3696        let events = queue_payloads(&rt, "del_log");
3697        assert_eq!(events.len(), 1);
3698        let event = events[0].as_object().expect("event payload object");
3699        assert_eq!(
3700            event.get("op").and_then(crate::json::Value::as_str),
3701            Some("delete")
3702        );
3703        assert_eq!(
3704            event.get("collection").and_then(crate::json::Value::as_str),
3705            Some("users")
3706        );
3707        assert!(event
3708            .get("event_id")
3709            .and_then(crate::json::Value::as_str)
3710            .is_some_and(|v| !v.is_empty()));
3711        let before = event
3712            .get("before")
3713            .and_then(crate::json::Value::as_object)
3714            .expect("before must be an object for delete");
3715        assert_eq!(
3716            before.get("id").and_then(crate::json::Value::as_u64),
3717            Some(42)
3718        );
3719        assert_eq!(
3720            before.get("name").and_then(crate::json::Value::as_str),
3721            Some("Alice")
3722        );
3723        assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
3724    }
3725
3726    #[test]
3727    fn multi_row_delete_emits_one_event_per_row() {
3728        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3729        rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
3730            .unwrap();
3731        rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
3732            .unwrap();
3733
3734        rt.execute_query("DELETE FROM items").unwrap();
3735
3736        let events = queue_payloads(&rt, "del_log");
3737        assert_eq!(events.len(), 3, "expected one delete event per deleted row");
3738        for event in &events {
3739            let obj = event.as_object().unwrap();
3740            assert_eq!(
3741                obj.get("op").and_then(crate::json::Value::as_str),
3742                Some("delete")
3743            );
3744            assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
3745        }
3746    }
3747
3748    #[test]
3749    fn ops_filter_update_does_not_emit_on_insert_or_delete() {
3750        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3751        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
3752            .unwrap();
3753
3754        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3755            .unwrap();
3756        rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3757
3758        let events = queue_payloads(&rt, "evts");
3759        assert!(
3760            events.is_empty(),
3761            "UPDATE-only filter must not emit INSERT or DELETE events"
3762        );
3763    }
3764
3765    // ── SUPPRESS EVENTS ────────────────────────────────────────────────────
3766
3767    #[test]
3768    fn suppress_events_on_insert_emits_no_events() {
3769        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3770        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3771            .unwrap();
3772
3773        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3774            .unwrap();
3775
3776        let events = queue_payloads(&rt, "evts");
3777        assert!(
3778            events.is_empty(),
3779            "SUPPRESS EVENTS must prevent INSERT events"
3780        );
3781    }
3782
3783    #[test]
3784    fn suppress_events_on_update_emits_no_events() {
3785        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3786        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3787            .unwrap();
3788        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3789            .unwrap();
3790        // drain the INSERT event
3791        let _ = queue_payloads(&rt, "evts");
3792        // Force pop to drain; simpler: just check new count after UPDATE
3793        rt.execute_query("QUEUE PURGE evts").unwrap();
3794
3795        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
3796            .unwrap();
3797
3798        let events = queue_payloads(&rt, "evts");
3799        assert!(
3800            events.is_empty(),
3801            "SUPPRESS EVENTS must prevent UPDATE events"
3802        );
3803    }
3804
3805    #[test]
3806    fn suppress_events_on_delete_emits_no_events() {
3807        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3808        rt.execute_query(
3809            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
3810        )
3811        .unwrap();
3812        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3813            .unwrap();
3814
3815        rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
3816            .unwrap();
3817
3818        let events = queue_payloads(&rt, "evts");
3819        assert!(
3820            events.is_empty(),
3821            "SUPPRESS EVENTS must prevent DELETE events"
3822        );
3823    }
3824
3825    #[test]
3826    fn normal_insert_after_suppress_still_emits() {
3827        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3828        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3829            .unwrap();
3830
3831        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3832            .unwrap();
3833        rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
3834            .unwrap();
3835
3836        let events = queue_payloads(&rt, "evts");
3837        assert_eq!(
3838            events.len(),
3839            1,
3840            "only the non-suppressed INSERT should emit"
3841        );
3842        assert_eq!(
3843            events[0].get("id").and_then(crate::json::Value::as_u64),
3844            Some(2)
3845        );
3846    }
3847}