Skip to main content

reddb_server/runtime/
impl_dml.rs

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